From cae0c584337f185e458d171d7517b7fa61ee745d Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 7 Jan 2026 15:20:45 +0100 Subject: [PATCH 1/3] spec: finalize 040 plan/tasks/checklist --- .../checklists/requirements.md | 45 ++++++ specs/040-inventory-core/plan.md | 134 +++++++++++++++--- specs/040-inventory-core/spec.md | 54 +++++++ specs/040-inventory-core/tasks.md | 40 +++--- 4 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 specs/040-inventory-core/checklists/requirements.md diff --git a/specs/040-inventory-core/checklists/requirements.md b/specs/040-inventory-core/checklists/requirements.md new file mode 100644 index 0000000..6560703 --- /dev/null +++ b/specs/040-inventory-core/checklists/requirements.md @@ -0,0 +1,45 @@ +# Requirements Checklist — Inventory Core (040) + +## Scope + +- [x] This checklist applies only to Spec 040 (Inventory Core). + +## Constitution Gates + +- [x] Inventory-first: inventory stores “last observed” only (no snapshot/backup side effects) +- [x] Read/write separation: no Intune write paths introduced +- [x] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used) +- [x] Tenant isolation: all reads/writes tenant-scoped +- [x] Automation is idempotent & observable: run records + locks + stable error codes +- [x] Data minimization & safe logging: no secrets/tokens; DB run records are the source of truth + +## Functional Requirements Coverage + +- [x] FR-001 Inventory catalog exists (inventory_items) +- [x] FR-002 Stable upsert identity prevents duplicates +- [x] FR-003 Sync runs recorded (inventory_sync_runs) +- [x] FR-004 Tenant isolation enforced +- [x] FR-005 Deterministic selection_hash implemented and tested +- [x] FR-006 Sync does NOT create snapshots/backups (explicitly tested) +- [x] FR-007 Missing is derived (not persisted) relative to latestRun(tenant_id, selection_hash) +- [x] FR-008 meta_jsonb whitelist enforced; unknown keys dropped; never fails sync +- [x] FR-009 Locks/idempotency/observable failures implemented + +## Non-Functional Requirements Coverage + +- [x] NFR-001 Global + per-tenant concurrency limits (source + defaults + behavior defined) +- [x] NFR-002 Throttling resilience (429/503 backoff+jitter) with stable error codes +- [x] NFR-003 Deterministic behavior is testable (Pest) +- [x] NFR-004 Data minimization enforced (no raw payload storage) +- [x] NFR-005 Safe logging: no secrets/tokens; rely on run records not log parsing + +## Tests (Pest) + +- [x] Upsert prevents duplicates + updates last_seen fields +- [x] selection_hash determinism (order invariant) +- [x] Missing semantics per latest completed run and selection isolation +- [x] Low-confidence missing when latest run partial/failed/had_errors +- [x] meta whitelist drops unknown keys +- [x] Lock prevents overlapping runs for same tenant+selection +- [x] No snapshot/backup tables are written during sync +- [x] Error reporting uses stable error codes; no secrets/tokens persisted in run error details diff --git a/specs/040-inventory-core/plan.md b/specs/040-inventory-core/plan.md index 01099f4..5e59e6f 100644 --- a/specs/040-inventory-core/plan.md +++ b/specs/040-inventory-core/plan.md @@ -1,32 +1,126 @@ # Implementation Plan: Inventory Core (040) -**Branch**: `feat/040-inventory-core` | **Date**: 2026-01-07 | **Spec**: `specs/040-inventory-core/spec.md` +**Branch**: `spec/040-inventory-core` | **Date**: 2026-01-07 | **Spec**: `specs/040-inventory-core/spec.md` +**Scope (this step)**: Produce a clean, implementable `plan.md` + consistent `tasks.md` for Spec 040 only. ## Summary -Implement a tenant-scoped inventory catalog (“last observed”) and an observable sync run system with deterministic selection scoping. Ensure no snapshots/backups are created by sync. +Implement tenant-scoped Inventory + Sync Run tracking as the foundational substrate for later Inventory UI and higher-order features. + +Key outcomes: + +- Inventory is “last observed” (not backup), stored as metadata + whitelisted `meta_jsonb`. +- Sync runs are observable, selection-scoped via deterministic `selection_hash`. +- “Missing” is derived relative to latest completed run for the same `(tenant_id, selection_hash)`. +- Automation is safe: locks, idempotency, throttling handling, global+per-tenant concurrency limits. + +## Technical Context + +- **Language/Version**: PHP 8.4 +- **Framework**: Laravel 12 +- **Admin UI**: Filament v4 + Livewire v3 +- **Storage**: PostgreSQL (JSONB available) +- **Queue/Locks**: Laravel queue + cache/Redis locks (as configured) +- **Testing**: Pest v4 (`php artisan test`) +- **Target Platform**: Sail-first local dev; container deploy (Dokploy) ## Constitution Check -- Inventory-first, snapshots-second (sync never creates snapshots) -- Read/write separation (sync is read-only; any future writes require preview/confirmation/audit/tests) -- Single contract path to Graph (Graph access only through existing abstractions/contracts) -- Deterministic capabilities (capabilities resolver output testable) -- Tenant isolation (non-negotiable) -- Automation observable + idempotent (locks, run records, stable error codes, 429/503 handling) -- Data minimization + safe logging +- Inventory-first: inventory stores last observed state only (no snapshot/backup side effects). +- Read/write separation: this feature introduces no Intune write paths. +- Single contract path to Graph: Graph reads (if needed) go via Graph abstraction and contracts. +- Tenant isolation: all reads/writes tenant-scoped; no cross-tenant shortcuts. +- Automation: locked + idempotent + observable; handle 429/503 with backoff+jitter. +- Data minimization: no payload-heavy storage; safe logs. -## Deliverables (Phase-friendly) +No constitution violations expected. -- Data model for inventory items and sync runs -- Sync engine orchestration and locking strategy -- Deterministic selection hashing -- Capabilities resolver output snapshot tests -- Minimal Filament/CLI surface to trigger and observe sync runs (if required by tasks) +## Project Structure (Impacted Areas) -## Out of Scope +```text +specs/040-inventory-core/ +├── spec.md +├── plan.md +└── tasks.md -- Dependency graph hydration (spec 042) -- Cross-tenant promotion (spec 043) -- Drift reporting (spec 044) -- Lifecycle “deleted” semantics (feature 900) +app/ +├── Models/ +├── Jobs/ +├── Services/ +└── Support/ + +database/migrations/ +tests/Feature/ +tests/Unit/ +``` + +## Implementation Approach + +### Phase A — Data Model + Migrations + +1. Add `inventory_items` table + - Identity: unique constraint to prevent duplicates, recommended: + - `(tenant_id, policy_type, external_id)` + - Fields: `display_name`, `platform`/`category` (if applicable), `meta_jsonb`, `last_seen_at`, `last_seen_run_id`. + - Indexing: indexes supporting tenant/type listing; consider partials as needed. + +2. Add `inventory_sync_runs` table + - Identity: `tenant_id`, `selection_hash` + - Status fields: `status`, `started_at`, `finished_at`, `had_errors` + - Counters: `items_observed_count`, `items_upserted_count`, `errors_count` + - Error reporting: stable error code(s) list or summary field. + +### Phase B — Selection Hash (Deterministic) + +Implement canonicalization exactly as Spec Appendix: + +- Only include scope-affecting keys in `selection_payload`. +- Sort object keys; sort `policy_types[]` and `categories[]` arrays. +- Compute `selection_hash = sha256(canonical_json(selection_payload))`. + +### Phase C — Sync Run Lifecycle + Upsert + +- Create a service that: + - acquires a lock for `(tenant_id, selection_hash)` + - creates a run record + - enumerates selected policy types + - upserts inventory items by identity key + - updates `last_seen_at` and `last_seen_run_id` per observed item + - finalizes run status + counters + - never creates/modifies snapshot/backup records (`policy_versions`, `backup_*`) + +### Phase D — Derived “Missing” Semantics + +- Implement “missing” as a computed state relative to `latestRun(tenant_id, selection_hash)`. +- Do not persist “missing” or “deleted”. +- Mark missing as low-confidence when `latestRun.status != success` or `latestRun.had_errors = true`. + +### Phase E — Meta Whitelist + +- Define a whitelist of allowed `meta_jsonb` keys. +- Enforce by dropping unknown keys (never fail sync). + +### Phase F — Concurrency Limits + +- Enforce global concurrency (across tenants) and per-tenant concurrency. +- The implementation may be via queue worker limits, semaphore/lock strategy, or both; the behavior must be testable. +- When limits are hit, create an observable run record with `status=skipped`, `had_errors=true`, and stable error code(s). + +## Test Plan (Pest) + +Minimum required coverage aligned to Spec test cases: + +- Upsert identity prevents duplicates; `last_seen_*` updates. +- `selection_hash` determinism (array ordering invariant). +- Missing derived per latest completed run for same `(tenant_id, selection_hash)`. +- Low-confidence missing when latest run is partial/failed or had_errors. +- Meta whitelist drops unknown keys. +- Lock prevents overlapping runs per tenant+selection. +- No snapshot/backup rows are created/modified by inventory sync. +- Error reporting uses stable `error_codes` and stores no secrets/tokens. + +## Out of Scope (Explicit) + +- Any UI (covered by Spec 041) +- Any snapshot/backup creation +- Any restore/promotion/remediation write paths diff --git a/specs/040-inventory-core/spec.md b/specs/040-inventory-core/spec.md index b3de53a..301b7de 100644 --- a/specs/040-inventory-core/spec.md +++ b/specs/040-inventory-core/spec.md @@ -140,11 +140,64 @@ ### Meta Whitelist (Fail-safe) - `meta_jsonb` has a documented whitelist of allowed keys. - **AC:** Unknown `meta_jsonb` keys are dropped (not persisted) and MUST NOT cause sync to fail. +#### Initial `meta_jsonb` whitelist (v1) + +Allowed keys (all optional; if not applicable for a type, omit): + +- `odata_type`: string (copied from Graph `@odata.type`) +- `etag`: string|null (Graph etag if available; never treated as a secret) +- `scope_tag_ids`: array (IDs only; no display names required) +- `assignment_target_count`: int|null (count only; no target details) +- `warnings`: array (bounded, human-readable, no secrets) + +**AC:** Any other key is dropped silently (not persisted) and MUST NOT fail sync. + ### Observed Run - `inventory_items.last_seen_run_id` and `inventory_items.last_seen_at` are updated when an item is observed. - `last_seen_run_id` implies the selection via `sync_runs.selection_hash`; no per-item selection hash is required for core. +### Run Error Codes (taxonomy) + +Sync runs record: + +- `status`: one of `success|partial|failed|skipped` +- `had_errors`: bool (true if any non-ideal condition occurred) +- `error_codes[]`: array of stable machine-readable codes (no secrets) + +Minimal taxonomy (3–8 codes): + +- `lock_contended` (a run could not start because the per-tenant+selection lock is held) +- `concurrency_limit_global` (global concurrency limit reached; run skipped) +- `concurrency_limit_tenant` (per-tenant concurrency limit reached; run skipped) +- `graph_throttled` (429 encountered; run partial/failed depending on recovery) +- `graph_transient` (503/timeout/other transient errors) +- `graph_forbidden` (403/insufficient permission) +- `unexpected_exception` (unexpected failure; message must be safe/redacted) + +**Rule:** Run records MUST store codes (and safe, bounded context) rather than raw exception dumps or tokens. + +### Concurrency Limits (source, defaults, behavior) + +**Source:** Config (recommended keys): + +- `tenantpilot.inventory_sync.concurrency.global_max` +- `tenantpilot.inventory_sync.concurrency.per_tenant_max` + +**Defaults (if not configured):** + +- global_max = 2 +- per_tenant_max = 1 + +**Behavior when limits are hit:** + +- The system MUST create a Sync Run record with: + - `status = skipped` + - `had_errors = true` (so missing stays low-confidence for that selection) + - `error_codes[]` includes `concurrency_limit_global` or `concurrency_limit_tenant` + - `started_at`/`finished_at` set (observable) +- No inventory items are mutated in a skipped run. + ## Testing Guidance (non-implementation) These are test cases expressed in behavior terms (not code). @@ -154,6 +207,7 @@ ### Test Cases — Sync and Upsert - **TC-001**: Sync creates or updates inventory items and sets `last_seen_at`. - **TC-002**: Re-running sync for the same tenant+selection updates existing records and does not create duplicates. - **TC-003**: Inventory queries scoped to Tenant A never return Tenant B’s items. +- **TC-004**: Inventory sync does not create or modify snapshot/backup records (e.g., no new rows in `policy_versions`, `backup_sets`, `backup_items`, `backup_schedules`, `backup_schedule_runs`). ### Test Cases — Selection Hash Determinism diff --git a/specs/040-inventory-core/tasks.md b/specs/040-inventory-core/tasks.md index 20b4ed3..217c800 100644 --- a/specs/040-inventory-core/tasks.md +++ b/specs/040-inventory-core/tasks.md @@ -4,29 +4,35 @@ # Tasks: Inventory Core (040) ## P1 — MVP (US1/US2) -- [ ] T001 [US1] Define Inventory Item data model (tenant-scoped identity + last_seen fields) -- [ ] T002 [US1] Define Sync Run data model (tenant_id, selection_hash, status, timestamps, counts, stable error codes) -- [ ] T003 [US1] Implement deterministic selection hashing (canonical json + sha256) -- [ ] T004 [US1] Implement inventory upsert semantics (no duplicates) -- [ ] T005 [US1] Enforce tenant isolation in all inventory/run queries -- [ ] T006 [US2] Implement derived “missing” computation relative to latest completed run (tenant_id + selection_hash) -- [ ] T007 [US2] Ensure low-confidence missing when latestRun is partial/failed or had_errors -- [ ] T008 [US2] Implement meta_jsonb whitelist enforcement (drop unknown keys, never fail sync) +- [ ] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields) +- [ ] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes) +- [ ] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256) +- [ ] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates) +- [ ] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths +- [ ] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash) +- [ ] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence +- [ ] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync) +- [ ] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`) ## P2 — Observability & Safety (US3 + NFR) -- [ ] T009 [US3] Ensure run records include stable error codes and counts -- [ ] T010 [NFR] Add idempotency + locks to prevent overlapping runs per tenant+selection -- [ ] T011 [NFR] Add global + per-tenant concurrency limiting strategy -- [ ] T012 [NFR] Implement throttling handling strategy (backoff + jitter for transient Graph failures) +- [ ] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable) +- [ ] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash) +- [ ] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy) +- [ ] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503) +- [ ] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required) ## Tests (Required for runtime behavior) -- [ ] T020 [US1] Tests: upsert does not create duplicates; last_seen updated -- [ ] T021 [US2] Tests: missing derived per latestRun(selection_hash); selection isolation -- [ ] T022 [US2] Tests: partial/failed run => low confidence missing -- [ ] T023 [US2] Tests: meta whitelist drops unknown keys without failing -- [ ] T024 [NFR] Tests: selection_hash determinism (array ordering) +- [ ] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update +- [ ] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash) +- [ ] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X) +- [ ] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence +- [ ] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence) +- [ ] T025 [NFR] Pest: selection_hash determinism (array ordering invariant) +- [ ] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash) +- [ ] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes) +- [ ] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged) ## Notes -- 2.45.2 From 63f4f878ad334cd244dc70b3b45d931ba5caf8d4 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 7 Jan 2026 15:51:47 +0100 Subject: [PATCH 2/3] feat: inventory core schema + sync --- app/Models/InventoryItem.php | 30 ++ app/Models/InventorySyncRun.php | 52 +++ .../Inventory/InventoryConcurrencyLimiter.php | 40 +++ .../Inventory/InventoryMetaSanitizer.php | 101 ++++++ .../Inventory/InventoryMissingService.php | 66 ++++ .../Inventory/InventorySelectionHasher.php | 89 +++++ .../Inventory/InventorySyncService.php | 323 ++++++++++++++++++ config/tenantpilot.php | 7 + database/factories/InventoryItemFactory.php | 38 +++ .../factories/InventorySyncRunFactory.php | 43 +++ ...07_142719_create_inventory_items_table.php | 19 ++ ...42719_create_inventory_sync_runs_table.php | 46 +++ ...07_142720_create_inventory_items_table.php | 38 +++ specs/040-inventory-core/tasks.md | 46 +-- .../Inventory/InventorySyncServiceTest.php | 296 ++++++++++++++++ .../InventorySelectionHasherTest.php | 23 ++ 16 files changed, 1234 insertions(+), 23 deletions(-) create mode 100644 app/Models/InventoryItem.php create mode 100644 app/Models/InventorySyncRun.php create mode 100644 app/Services/Inventory/InventoryConcurrencyLimiter.php create mode 100644 app/Services/Inventory/InventoryMetaSanitizer.php create mode 100644 app/Services/Inventory/InventoryMissingService.php create mode 100644 app/Services/Inventory/InventorySelectionHasher.php create mode 100644 app/Services/Inventory/InventorySyncService.php create mode 100644 database/factories/InventoryItemFactory.php create mode 100644 database/factories/InventorySyncRunFactory.php create mode 100644 database/migrations/2026_01_07_142719_create_inventory_items_table.php create mode 100644 database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php create mode 100644 database/migrations/2026_01_07_142720_create_inventory_items_table.php create mode 100644 tests/Feature/Inventory/InventorySyncServiceTest.php create mode 100644 tests/Unit/Inventory/InventorySelectionHasherTest.php diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 0000000..73b75a8 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,30 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'meta_jsonb' => 'array', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function lastSeenRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); + } +} diff --git a/app/Models/InventorySyncRun.php b/app/Models/InventorySyncRun.php new file mode 100644 index 0000000..9250153 --- /dev/null +++ b/app/Models/InventorySyncRun.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public const STATUS_RUNNING = 'running'; + + public const STATUS_SUCCESS = 'success'; + + public const STATUS_PARTIAL = 'partial'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_SKIPPED = 'skipped'; + + protected $guarded = []; + + protected $casts = [ + 'selection_payload' => 'array', + 'had_errors' => 'boolean', + 'error_codes' => 'array', + 'error_context' => 'array', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function scopeCompleted(Builder $query): Builder + { + return $query + ->whereIn('status', [ + self::STATUS_SUCCESS, + self::STATUS_PARTIAL, + self::STATUS_FAILED, + self::STATUS_SKIPPED, + ]) + ->whereNotNull('finished_at'); + } +} diff --git a/app/Services/Inventory/InventoryConcurrencyLimiter.php b/app/Services/Inventory/InventoryConcurrencyLimiter.php new file mode 100644 index 0000000..1dda447 --- /dev/null +++ b/app/Services/Inventory/InventoryConcurrencyLimiter.php @@ -0,0 +1,40 @@ +acquireSlot('inventory_sync:global:slot:', $max); + } + + public function acquireTenantSlot(int $tenantId): ?Lock + { + $max = (int) config('tenantpilot.inventory_sync.concurrency.per_tenant_max', 1); + $max = max(0, $max); + + return $this->acquireSlot("inventory_sync:tenant:{$tenantId}:slot:", $max); + } + + private function acquireSlot(string $prefix, int $max): ?Lock + { + for ($slot = 0; $slot < $max; $slot++) { + $lock = Cache::lock($prefix.$slot, $this->lockTtlSeconds); + + if ($lock->get()) { + return $lock; + } + } + + return null; + } +} diff --git a/app/Services/Inventory/InventoryMetaSanitizer.php b/app/Services/Inventory/InventoryMetaSanitizer.php new file mode 100644 index 0000000..29e1bb3 --- /dev/null +++ b/app/Services/Inventory/InventoryMetaSanitizer.php @@ -0,0 +1,101 @@ + $meta + * @return array{odata_type?: string, etag?: string|null, scope_tag_ids?: list, assignment_target_count?: int|null, warnings?: list} + */ + public function sanitize(array $meta): array + { + $sanitized = []; + + $odataType = $meta['odata_type'] ?? null; + if (is_string($odataType) && trim($odataType) !== '') { + $sanitized['odata_type'] = trim($odataType); + } + + $etag = $meta['etag'] ?? null; + if ($etag === null || is_string($etag)) { + $sanitized['etag'] = $etag === null ? null : trim($etag); + } + + $scopeTagIds = $meta['scope_tag_ids'] ?? null; + if (is_array($scopeTagIds)) { + $sanitized['scope_tag_ids'] = $this->stringList($scopeTagIds); + } + + $assignmentTargetCount = $meta['assignment_target_count'] ?? null; + if (is_int($assignmentTargetCount)) { + $sanitized['assignment_target_count'] = $assignmentTargetCount; + } elseif (is_numeric($assignmentTargetCount)) { + $sanitized['assignment_target_count'] = (int) $assignmentTargetCount; + } elseif ($assignmentTargetCount === null) { + $sanitized['assignment_target_count'] = null; + } + + $warnings = $meta['warnings'] ?? null; + if (is_array($warnings)) { + $sanitized['warnings'] = $this->boundedStringList($warnings, 25, 200); + } + + return array_filter( + $sanitized, + static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '' + ); + } + + /** + * @param list $values + * @return list + */ + private function stringList(array $values): array + { + $result = []; + + foreach ($values as $value) { + if (! is_string($value)) { + continue; + } + + $value = trim($value); + if ($value === '') { + continue; + } + + $result[] = $value; + } + + return array_values(array_unique($result)); + } + + /** + * @param list $values + * @return list + */ + private function boundedStringList(array $values, int $maxItems, int $maxLen): array + { + $items = []; + + foreach ($values as $value) { + if (count($items) >= $maxItems) { + break; + } + + if (! is_string($value)) { + continue; + } + + $value = trim($value); + if ($value === '') { + continue; + } + + $items[] = mb_substr($value, 0, $maxLen); + } + + return array_values(array_unique($items)); + } +} diff --git a/app/Services/Inventory/InventoryMissingService.php b/app/Services/Inventory/InventoryMissingService.php new file mode 100644 index 0000000..b1ff4e6 --- /dev/null +++ b/app/Services/Inventory/InventoryMissingService.php @@ -0,0 +1,66 @@ + $selectionPayload + * @return array{latestRun: InventorySyncRun|null, missing: Collection, lowConfidence: bool} + */ + public function missingForSelection(Tenant $tenant, array $selectionPayload): array + { + $normalized = $this->selectionHasher->normalize($selectionPayload); + $normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']); + $selectionHash = $this->selectionHasher->hash($normalized); + + $latestRun = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $selectionHash) + ->whereIn('status', [ + InventorySyncRun::STATUS_SUCCESS, + InventorySyncRun::STATUS_PARTIAL, + InventorySyncRun::STATUS_FAILED, + InventorySyncRun::STATUS_SKIPPED, + ]) + ->orderByDesc('finished_at') + ->orderByDesc('id') + ->first(); + + if (! $latestRun) { + return [ + 'latestRun' => null, + 'missing' => InventoryItem::query()->whereRaw('1 = 0')->get(), + 'lowConfidence' => true, + ]; + } + + $missingQuery = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('policy_type', $normalized['policy_types']) + ->where(function ($query) use ($latestRun): void { + $query + ->whereNull('last_seen_run_id') + ->orWhere('last_seen_run_id', '!=', $latestRun->getKey()); + }); + + $lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false); + + return [ + 'latestRun' => $latestRun, + 'missing' => $missingQuery->get(), + 'lowConfidence' => $lowConfidence, + ]; + } +} diff --git a/app/Services/Inventory/InventorySelectionHasher.php b/app/Services/Inventory/InventorySelectionHasher.php new file mode 100644 index 0000000..3ea249c --- /dev/null +++ b/app/Services/Inventory/InventorySelectionHasher.php @@ -0,0 +1,89 @@ + $selectionPayload + * @return array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} + */ + public function normalize(array $selectionPayload): array + { + $policyTypes = $this->stringList($selectionPayload['policy_types'] ?? []); + sort($policyTypes); + + $categories = $this->stringList($selectionPayload['categories'] ?? []); + sort($categories); + + return [ + 'policy_types' => $policyTypes, + 'categories' => $categories, + 'include_foundations' => (bool) ($selectionPayload['include_foundations'] ?? false), + 'include_dependencies' => (bool) ($selectionPayload['include_dependencies'] ?? false), + ]; + } + + /** + * @param array $selectionPayload + */ + public function canonicalJson(array $selectionPayload): string + { + $normalized = $this->normalize($selectionPayload); + $normalized = $this->ksortRecursive($normalized); + + return (string) json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * @param array $selectionPayload + */ + public function hash(array $selectionPayload): string + { + return hash('sha256', $this->canonicalJson($selectionPayload)); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $item) { + if (! is_string($item)) { + continue; + } + + $item = trim($item); + if ($item === '') { + continue; + } + + $result[] = $item; + } + + return array_values(array_unique($result)); + } + + private function ksortRecursive(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $isList = array_is_list($value); + if (! $isList) { + ksort($value); + } + + foreach ($value as $key => $child) { + $value[$key] = $this->ksortRecursive($child); + } + + return $value; + } +} diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php new file mode 100644 index 0000000..69f8e44 --- /dev/null +++ b/app/Services/Inventory/InventorySyncService.php @@ -0,0 +1,323 @@ + $selectionPayload + */ + public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun + { + $normalizedSelection = $this->selectionHasher->normalize($selectionPayload); + $normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); + $selectionHash = $this->selectionHasher->hash($normalizedSelection); + + $now = CarbonImmutable::now('UTC'); + + $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); + if (! $globalSlot instanceof Lock) { + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'concurrency_limit_global', + ); + } + + $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); + if (! $tenantSlot instanceof Lock) { + $globalSlot->release(); + + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'concurrency_limit_tenant', + ); + } + + $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); + if (! $selectionLock->get()) { + $tenantSlot->release(); + $globalSlot->release(); + + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'lock_contended', + ); + } + + $run = InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $normalizedSelection, + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => $now, + 'finished_at' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + + try { + return $this->executeRun($run, $tenant, $normalizedSelection); + } finally { + $selectionLock->release(); + $tenantSlot->release(); + $globalSlot->release(); + } + } + + /** + * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection + */ + private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection): InventorySyncRun + { + $observed = 0; + $upserted = 0; + $errors = 0; + $errorCodes = []; + $hadErrors = false; + + try { + $typesConfig = $this->supportedTypeConfigByType(); + + foreach ($normalizedSelection['policy_types'] as $policyType) { + $typeConfig = $typesConfig[$policyType] ?? null; + + if (! is_array($typeConfig)) { + continue; + } + + $response = $this->listPoliciesWithRetry($policyType, [ + 'tenant' => $tenant->tenant_id ?? $tenant->external_id, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $typeConfig['platform'] ?? null, + 'filter' => $typeConfig['filter'] ?? null, + ]); + + if ($response->failed()) { + $hadErrors = true; + $errors++; + $errorCodes[] = $this->mapGraphFailureToErrorCode($response); + + continue; + } + + foreach ($response->data as $policyData) { + if (! is_array($policyData)) { + continue; + } + + $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; + if (! is_string($externalId) || $externalId === '') { + continue; + } + + $observed++; + + $displayName = $policyData['displayName'] ?? $policyData['name'] ?? null; + $displayName = is_string($displayName) ? $displayName : null; + + $scopeTagIds = $policyData['roleScopeTagIds'] ?? null; + $assignmentTargetCount = null; + $assignments = $policyData['assignments'] ?? null; + if (is_array($assignments)) { + $assignmentTargetCount = count($assignments); + } + + $meta = $this->metaSanitizer->sanitize([ + 'odata_type' => $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null, + 'etag' => $policyData['@odata.etag'] ?? null, + 'scope_tag_ids' => is_array($scopeTagIds) ? $scopeTagIds : null, + 'assignment_target_count' => $assignmentTargetCount, + 'warnings' => [], + ]); + + InventoryItem::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId, + ], + [ + 'display_name' => $displayName, + 'category' => $typeConfig['category'] ?? null, + 'platform' => $typeConfig['platform'] ?? null, + 'meta_jsonb' => $meta, + 'last_seen_at' => now(), + 'last_seen_run_id' => $run->getKey(), + ] + ); + + $upserted++; + } + } + + $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; + + $run->update([ + 'status' => $status, + 'had_errors' => $hadErrors, + 'error_codes' => array_values(array_unique($errorCodes)), + 'error_context' => null, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'errors_count' => $errors, + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return $run->refresh(); + } catch (Throwable $throwable) { + $run->update([ + 'status' => InventorySyncRun::STATUS_FAILED, + 'had_errors' => true, + 'error_codes' => ['unexpected_exception'], + 'error_context' => $this->safeErrorContext($throwable), + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'errors_count' => $errors + 1, + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return $run->refresh(); + } + } + + /** + * @return array> + */ + private function supportedTypeConfigByType(): array + { + /** @var array> $supported */ + $supported = config('tenantpilot.supported_policy_types', []); + + $byType = []; + foreach ($supported as $config) { + $type = $config['type'] ?? null; + if (is_string($type) && $type !== '') { + $byType[$type] = $config; + } + } + + return $byType; + } + + private function selectionLockKey(Tenant $tenant, string $selectionHash): string + { + return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); + } + + /** + * @param array $selectionPayload + */ + private function createSkippedRun( + Tenant $tenant, + string $selectionHash, + array $selectionPayload, + CarbonImmutable $now, + string $errorCode, + ): InventorySyncRun { + return InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $selectionPayload, + 'status' => InventorySyncRun::STATUS_SKIPPED, + 'had_errors' => true, + 'error_codes' => [$errorCode], + 'error_context' => null, + 'started_at' => $now, + 'finished_at' => $now, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + } + + private function mapGraphFailureToErrorCode(GraphResponse $response): string + { + $status = (int) ($response->status ?? 0); + + return match ($status) { + 403 => 'graph_forbidden', + 429 => 'graph_throttled', + 503 => 'graph_transient', + default => 'graph_transient', + }; + } + + private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse + { + $maxAttempts = 3; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $response = $this->graphClient->listPolicies($policyType, $options); + + if (! $response->failed()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + if (! in_array($status, [429, 503], true)) { + return $response; + } + + if ($attempt >= $maxAttempts) { + return $response; + } + + $baseMs = 250 * (2 ** ($attempt - 1)); + $jitterMs = random_int(0, 250); + usleep(($baseMs + $jitterMs) * 1000); + } + + return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]); + } + + /** + * @return array + */ + private function safeErrorContext(Throwable $throwable): array + { + $message = $throwable->getMessage(); + + $message = preg_replace('/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/', 'Bearer [REDACTED]', (string) $message); + $message = mb_substr((string) $message, 0, 500); + + return [ + 'exception_class' => get_class($throwable), + 'message' => $message, + ]; + } +} diff --git a/config/tenantpilot.php b/config/tenantpilot.php index d59d3cf..1a14ee0 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -320,6 +320,13 @@ 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), ], + 'inventory_sync' => [ + 'concurrency' => [ + 'global_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_GLOBAL_MAX', 2), + 'per_tenant_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_PER_TENANT_MAX', 1), + ], + ], + 'display' => [ 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 0000000..6cdffa2 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,38 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => fake()->uuid(), + 'display_name' => fake()->words(3, true), + 'category' => 'Configuration', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10', 'windows']), + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => null, + 'scope_tag_ids' => [], + 'assignment_target_count' => null, + 'warnings' => [], + ], + 'last_seen_at' => now(), + 'last_seen_run_id' => null, + ]; + } +} diff --git a/database/factories/InventorySyncRunFactory.php b/database/factories/InventorySyncRunFactory.php new file mode 100644 index 0000000..2246f57 --- /dev/null +++ b/database/factories/InventorySyncRunFactory.php @@ -0,0 +1,43 @@ + + */ +class InventorySyncRunFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $selectionPayload = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + return [ + 'tenant_id' => Tenant::factory(), + 'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)), + 'selection_payload' => $selectionPayload, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]; + } +} diff --git a/database/migrations/2026_01_07_142719_create_inventory_items_table.php b/database/migrations/2026_01_07_142719_create_inventory_items_table.php new file mode 100644 index 0000000..d36a82b --- /dev/null +++ b/database/migrations/2026_01_07_142719_create_inventory_items_table.php @@ -0,0 +1,19 @@ +id(); + + $table->foreignId('tenant_id')->constrained(); + + $table->string('selection_hash', 64); + $table->jsonb('selection_payload')->nullable(); + + $table->string('status'); + $table->boolean('had_errors')->default(false); + $table->jsonb('error_codes')->nullable(); + $table->jsonb('error_context')->nullable(); + + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('errors_count')->default(0); + + $table->timestamps(); + + $table->index(['tenant_id', 'selection_hash']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_sync_runs'); + } +}; diff --git a/database/migrations/2026_01_07_142720_create_inventory_items_table.php b/database/migrations/2026_01_07_142720_create_inventory_items_table.php new file mode 100644 index 0000000..5946a89 --- /dev/null +++ b/database/migrations/2026_01_07_142720_create_inventory_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->string('policy_type'); + $table->string('external_id'); + $table->string('display_name')->nullable(); + $table->string('category')->nullable(); + $table->string('platform')->nullable(); + $table->jsonb('meta_jsonb')->nullable(); + $table->timestampTz('last_seen_at')->nullable(); + $table->foreignId('last_seen_run_id') + ->nullable() + ->constrained('inventory_sync_runs') + ->nullOnDelete(); + $table->timestamps(); + + $table->unique(['tenant_id', 'policy_type', 'external_id']); + $table->index(['tenant_id', 'policy_type']); + $table->index(['tenant_id', 'category']); + $table->index('last_seen_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/specs/040-inventory-core/tasks.md b/specs/040-inventory-core/tasks.md index 217c800..8c44aa0 100644 --- a/specs/040-inventory-core/tasks.md +++ b/specs/040-inventory-core/tasks.md @@ -4,35 +4,35 @@ # Tasks: Inventory Core (040) ## P1 — MVP (US1/US2) -- [ ] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields) -- [ ] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes) -- [ ] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256) -- [ ] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates) -- [ ] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths -- [ ] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash) -- [ ] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence -- [ ] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync) -- [ ] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`) +- [X] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields) +- [X] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes) +- [X] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256) +- [X] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates) +- [X] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths +- [X] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash) +- [X] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence +- [X] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync) +- [X] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`) ## P2 — Observability & Safety (US3 + NFR) -- [ ] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable) -- [ ] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash) -- [ ] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy) -- [ ] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503) -- [ ] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required) +- [X] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable) +- [X] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash) +- [X] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy) +- [X] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503) +- [X] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required) ## Tests (Required for runtime behavior) -- [ ] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update -- [ ] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash) -- [ ] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X) -- [ ] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence -- [ ] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence) -- [ ] T025 [NFR] Pest: selection_hash determinism (array ordering invariant) -- [ ] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash) -- [ ] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes) -- [ ] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged) +- [X] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update +- [X] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash) +- [X] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X) +- [X] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence +- [X] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence) +- [X] T025 [NFR] Pest: selection_hash determinism (array ordering invariant) +- [X] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash) +- [X] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes) +- [X] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged) ## Notes diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php new file mode 100644 index 0000000..6f6fb7f --- /dev/null +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -0,0 +1,296 @@ +throwable instanceof Throwable) { + throw $this->throwable; + } + + if (in_array($policyType, $this->failedTypes, true)) { + return new GraphResponse(false, [], 403, ['error' => ['code' => 'Forbidden', 'message' => 'forbidden']], [], []); + } + + return new GraphResponse(true, $this->policiesByType[$policyType] ?? []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; +} + +test('inventory sync upserts and updates last_seen fields without duplicates', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + ])); + + $service = app(InventorySyncService::class); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $runA = $service->syncNow($tenant, $selection); + expect($runA->status)->toBe('success'); + + $item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first(); + expect($item)->not->toBeNull(); + expect($item->external_id)->toBe('cfg-1'); + expect($item->last_seen_run_id)->toBe($runA->id); + + $runB = $service->syncNow($tenant, $selection); + + $items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get(); + expect($items)->toHaveCount(1); + + $items->first()->refresh(); + expect($items->first()->last_seen_run_id)->toBe($runB->id); +}); + +test('meta whitelist drops unknown keys without failing', function () { + $tenant = Tenant::factory()->create(); + + $sanitizer = app(InventoryMetaSanitizer::class); + + $meta = $sanitizer->sanitize([ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'W/\"123\"', + 'scope_tag_ids' => ['0', 'tag-1'], + 'assignment_target_count' => '5', + 'warnings' => ['ok'], + 'unknown_key' => 'should_not_persist', + ]); + + $item = \App\Models\InventoryItem::query()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'cfg-1', + 'display_name' => 'Config 1', + 'meta_jsonb' => $meta, + 'last_seen_at' => now(), + 'last_seen_run_id' => null, + ]); + + $item->refresh(); + + $stored = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + + expect($stored)->not->toHaveKey('unknown_key'); + expect($stored['assignment_target_count'] ?? null)->toBe(5); +}); + +test('inventory missing is derived from latest completed run and low confidence on partial runs', function () { + $tenant = Tenant::factory()->create(); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + ])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + $missingService = app(InventoryMissingService::class); + $result = $missingService->missingForSelection($tenant, $selection); + + expect($result['missing'])->toHaveCount(1); + expect($result['lowConfidence'])->toBeFalse(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ], failedTypes: ['deviceConfiguration'])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + $result2 = $missingService->missingForSelection($tenant, $selection); + expect($result2['missing'])->toHaveCount(1); + expect($result2['lowConfidence'])->toBeTrue(); +}); + +test('selection isolation: run for selection Y does not affect selection X missing', function () { + $tenant = Tenant::factory()->create(); + + $selectionX = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $selectionY = [ + 'policy_types' => ['deviceCompliancePolicy'], + 'categories' => ['Compliance'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + 'deviceCompliancePolicy' => [ + ['id' => 'cmp-1', 'displayName' => 'Compliance 1', '@odata.type' => '#microsoft.graph.deviceCompliancePolicy'], + ], + ])); + + $service = app(InventorySyncService::class); + $service->syncNow($tenant, $selectionX); + + $service->syncNow($tenant, $selectionY); + + $missingService = app(InventoryMissingService::class); + $resultX = $missingService->missingForSelection($tenant, $selectionX); + + expect($resultX['missing'])->toHaveCount(0); +}); + +test('lock prevents overlapping runs for same tenant and selection', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + $service = app(InventorySyncService::class); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection); + $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); + expect($lock->get())->toBeTrue(); + + $run = $service->syncNow($tenant, $selection); + + expect($run->status)->toBe('skipped'); + expect($run->error_codes)->toContain('lock_contended'); + + $lock->release(); +}); + +test('inventory sync does not create snapshot or backup rows', function () { + $tenant = Tenant::factory()->create(); + + $baseline = [ + 'policy_versions' => PolicyVersion::query()->count(), + 'backup_sets' => BackupSet::query()->count(), + 'backup_items' => BackupItem::query()->count(), + 'backup_schedules' => BackupSchedule::query()->count(), + 'backup_schedule_runs' => BackupScheduleRun::query()->count(), + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + $service = app(InventorySyncService::class); + + $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect(PolicyVersion::query()->count())->toBe($baseline['policy_versions']); + expect(BackupSet::query()->count())->toBe($baseline['backup_sets']); + expect(BackupItem::query()->count())->toBe($baseline['backup_items']); + expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']); + expect(BackupScheduleRun::query()->count())->toBe($baseline['backup_schedule_runs']); +}); + +test('run error persistence is safe and does not include bearer tokens', function () { + $tenant = Tenant::factory()->create(); + + $throwable = new RuntimeException('Graph failed: Bearer abc.def.ghi'); + + app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable)); + + $service = app(InventorySyncService::class); + + $run = $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect($run->status)->toBe('failed'); + + $context = is_array($run->error_context) ? $run->error_context : []; + $message = (string) ($context['message'] ?? ''); + + expect($message)->not->toContain('abc.def.ghi'); + expect($message)->toContain('Bearer [REDACTED]'); +}); diff --git a/tests/Unit/Inventory/InventorySelectionHasherTest.php b/tests/Unit/Inventory/InventorySelectionHasherTest.php new file mode 100644 index 0000000..a77308a --- /dev/null +++ b/tests/Unit/Inventory/InventorySelectionHasherTest.php @@ -0,0 +1,23 @@ + ['deviceCompliancePolicy', 'deviceConfiguration'], + 'categories' => ['Compliance', 'Configuration'], + 'include_foundations' => true, + 'include_dependencies' => false, + ]; + + $payloadB = [ + 'include_dependencies' => false, + 'include_foundations' => true, + 'categories' => ['Configuration', 'Compliance'], + 'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + ]; + + expect($hasher->hash($payloadA))->toBe($hasher->hash($payloadB)); +}); -- 2.45.2 From c0301cd2539ce4b005aec0b3b36ac37d60a6b40d Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 7 Jan 2026 17:02:32 +0100 Subject: [PATCH 3/3] feat: inventory ui --- app/Filament/Pages/InventoryCoverage.php | 30 ++++ app/Filament/Pages/InventoryLanding.php | 36 ++++ .../Resources/InventoryItemResource.php | 161 ++++++++++++++++++ .../Pages/ListInventoryItems.php | 11 ++ .../Pages/ViewInventoryItem.php | 11 ++ .../Resources/InventorySyncRunResource.php | 137 +++++++++++++++ .../Pages/ListInventorySyncRuns.php | 11 ++ .../Pages/ViewInventorySyncRun.php | 11 ++ .../pages/inventory-coverage.blade.php | 28 +++ .../pages/inventory-landing.blade.php | 23 +++ specs/041-inventory-ui/tasks.md | 14 +- .../Filament/InventoryItemResourceTest.php | 41 +++++ tests/Feature/Filament/InventoryPagesTest.php | 26 +++ .../Filament/InventorySyncRunResourceTest.php | 37 ++++ 14 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 app/Filament/Pages/InventoryCoverage.php create mode 100644 app/Filament/Pages/InventoryLanding.php create mode 100644 app/Filament/Resources/InventoryItemResource.php create mode 100644 app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php create mode 100644 app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php create mode 100644 app/Filament/Resources/InventorySyncRunResource.php create mode 100644 app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php create mode 100644 app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php create mode 100644 resources/views/filament/pages/inventory-coverage.blade.php create mode 100644 resources/views/filament/pages/inventory-landing.blade.php create mode 100644 tests/Feature/Filament/InventoryItemResourceTest.php create mode 100644 tests/Feature/Filament/InventoryPagesTest.php create mode 100644 tests/Feature/Filament/InventorySyncRunResourceTest.php diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php new file mode 100644 index 0000000..74b888d --- /dev/null +++ b/app/Filament/Pages/InventoryCoverage.php @@ -0,0 +1,30 @@ +> + */ + public array $supportedTypes = []; + + public function mount(): void + { + $types = config('tenantpilot.supported_policy_types', []); + + $this->supportedTypes = is_array($types) ? $types : []; + } +} diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php new file mode 100644 index 0000000..71771c9 --- /dev/null +++ b/app/Filament/Pages/InventoryLanding.php @@ -0,0 +1,36 @@ +schema([ + Section::make('Inventory Item') + ->schema([ + TextEntry::make('display_name')->label('Name'), + TextEntry::make('policy_type') + ->label('Type') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type), + TextEntry::make('category') + ->badge() + ->state(fn (InventoryItem $record): string => $record->category + ?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')), + TextEntry::make('platform')->badge(), + TextEntry::make('external_id')->label('External ID'), + TextEntry::make('last_seen_at')->label('Last seen')->dateTime(), + TextEntry::make('last_seen_run_id') + ->label('Last sync run') + ->url(function (InventoryItem $record): ?string { + if (! $record->last_seen_run_id) { + return null; + } + + return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current()); + }) + ->openUrlInNewTab(), + TextEntry::make('support_restore') + ->label('Restore') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'), + TextEntry::make('support_risk') + ->label('Risk') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Metadata (Safe Subset)') + ->schema([ + ViewEntry::make('meta_jsonb') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventoryItem $record) => $record->meta_jsonb ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + $typeOptions = collect(config('tenantpilot.supported_policy_types', [])) + ->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', [])) + ->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)]) + ->filter(fn ($value, $key) => is_string($key) && $key !== '') + ->all(); + + return $table + ->defaultSort('last_seen_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('display_name') + ->label('Name') + ->searchable(), + Tables\Columns\TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state), + Tables\Columns\TextColumn::make('category') + ->badge(), + Tables\Columns\TextColumn::make('platform') + ->badge(), + Tables\Columns\TextColumn::make('last_seen_at') + ->label('Last seen') + ->since(), + Tables\Columns\TextColumn::make('lastSeenRun.status') + ->label('Run') + ->badge(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('policy_type') + ->options($typeOptions) + ->searchable(), + Tables\Filters\SelectFilter::make('category') + ->options($categoryOptions) + ->searchable(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->with('lastSeenRun'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInventoryItems::route('/'), + 'view' => Pages\ViewInventoryItem::route('/{record}'), + ]; + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } +} diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php new file mode 100644 index 0000000..fea1cdb --- /dev/null +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -0,0 +1,11 @@ +schema([ + Section::make('Sync Run') + ->schema([ + TextEntry::make('status') + ->badge() + ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), + TextEntry::make('selection_hash')->label('Selection hash')->copyable(), + TextEntry::make('started_at')->dateTime(), + TextEntry::make('finished_at')->dateTime(), + TextEntry::make('items_observed_count')->label('Observed')->numeric(), + TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), + TextEntry::make('errors_count')->label('Errors')->numeric(), + TextEntry::make('had_errors')->label('Had errors')->badge(), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Selection Payload') + ->schema([ + ViewEntry::make('selection_payload') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->selection_payload ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + + Section::make('Error Summary') + ->schema([ + ViewEntry::make('error_codes') + ->label('Error codes') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->error_codes ?? []) + ->columnSpanFull(), + ViewEntry::make('error_context') + ->label('Safe error context') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->error_context ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), + Tables\Columns\TextColumn::make('selection_hash') + ->label('Selection') + ->copyable() + ->limit(12), + Tables\Columns\TextColumn::make('started_at')->since(), + Tables\Columns\TextColumn::make('finished_at')->since(), + Tables\Columns\TextColumn::make('items_observed_count') + ->label('Observed') + ->numeric(), + Tables\Columns\TextColumn::make('items_upserted_count') + ->label('Upserted') + ->numeric(), + Tables\Columns\TextColumn::make('errors_count') + ->label('Errors') + ->numeric(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInventorySyncRuns::route('/'), + 'view' => Pages\ViewInventorySyncRun::route('/{record}'), + ]; + } + + private static function statusColor(?string $status): string + { + return match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'success', + InventorySyncRun::STATUS_PARTIAL => 'warning', + InventorySyncRun::STATUS_FAILED => 'danger', + InventorySyncRun::STATUS_SKIPPED => 'gray', + InventorySyncRun::STATUS_RUNNING => 'info', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php new file mode 100644 index 0000000..024f46e --- /dev/null +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php @@ -0,0 +1,11 @@ + + +
+ + + + + + + + + + + + @foreach ($supportedTypes as $row) + + + + + + + + @endforeach + +
TypeLabelCategoryRestoreRisk
{{ $row['type'] ?? '' }}{{ $row['label'] ?? '' }}{{ $row['category'] ?? '' }}{{ $row['restore'] ?? 'enabled' }}{{ $row['risk'] ?? 'normal' }}
+
+
+ diff --git a/resources/views/filament/pages/inventory-landing.blade.php b/resources/views/filament/pages/inventory-landing.blade.php new file mode 100644 index 0000000..a486e15 --- /dev/null +++ b/resources/views/filament/pages/inventory-landing.blade.php @@ -0,0 +1,23 @@ + + +
+
+ Browse inventory items, inspect sync runs, and review coverage/capabilities. +
+ +
+ + Inventory Items + + + + Sync Runs + + + + Coverage + +
+
+
+
diff --git a/specs/041-inventory-ui/tasks.md b/specs/041-inventory-ui/tasks.md index f105ffb..0630635 100644 --- a/specs/041-inventory-ui/tasks.md +++ b/specs/041-inventory-ui/tasks.md @@ -1,9 +1,9 @@ # Tasks: Inventory UI -- [ ] T001 Inventory landing page + navigation -- [ ] T002 Inventory list views per type/category -- [ ] T003 Inventory detail view with capability/support indicator -- [ ] T004 Sync runs list + detail -- [ ] T005 Coverage/capabilities view derived from config/contracts -- [ ] T006 Authorization + tenant isolation tests -- [ ] T007 Performance sanity checks (pagination/search) +- [x] T001 Inventory landing page + navigation +- [x] T002 Inventory list views per type/category +- [x] T003 Inventory detail view with capability/support indicator +- [x] T004 Sync runs list + detail +- [x] T005 Coverage/capabilities view derived from config/contracts +- [x] T006 Authorization + tenant isolation tests +- [x] T007 Performance sanity checks (pagination/search) diff --git a/tests/Feature/Filament/InventoryItemResourceTest.php b/tests/Feature/Filament/InventoryItemResourceTest.php new file mode 100644 index 0000000..feae540 --- /dev/null +++ b/tests/Feature/Filament/InventoryItemResourceTest.php @@ -0,0 +1,41 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'display_name' => 'Item B', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-b', + 'platform' => 'windows', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Item A') + ->assertDontSee('Item B'); +}); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php new file mode 100644 index 0000000..598e362 --- /dev/null +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -0,0 +1,26 @@ +create(); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventoryLanding::getUrl(tenant: $tenant)) + ->assertOk(); + + $this->actingAs($user) + ->get(InventoryCoverage::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Coverage'); +}); diff --git a/tests/Feature/Filament/InventorySyncRunResourceTest.php b/tests/Feature/Filament/InventorySyncRunResourceTest.php new file mode 100644 index 0000000..654eb3a --- /dev/null +++ b/tests/Feature/Filament/InventorySyncRunResourceTest.php @@ -0,0 +1,37 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'selection_hash' => str_repeat('b', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee(str_repeat('a', 12)) + ->assertDontSee(str_repeat('b', 12)); +}); -- 2.45.2