feat: add metadata-only mobile app coverage with scope tag restore (#10)
Summary add mobileApp contract details (assignments, expanded type family, scope tag select) and spec/test coverage so App snapshots stay metadata-only yet still capture roleScopeTagIds. guard restores so scope tags are written back whenever a snapshot carries them, even without explicit foundation mappings, and document it via a new Filament restore test. keep existing restore/sync behaviors in place while ensuring mobileApp assignments and metadata continue to flow through the backup/restore pipeline. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #10
This commit is contained in:
parent
3111aaf532
commit
47db966a19
@ -46,6 +46,14 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
'platform' => $policy->platform,
|
||||
];
|
||||
|
||||
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
||||
$select = $this->metadataOnlySelect($policy->policy_type);
|
||||
|
||||
if ($select !== []) {
|
||||
$options['select'] = $select;
|
||||
}
|
||||
}
|
||||
|
||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||
$options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)';
|
||||
}
|
||||
@ -110,6 +118,10 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$metadataWarnings = $response->warnings ?? [$reason];
|
||||
}
|
||||
|
||||
if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
||||
$payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []);
|
||||
}
|
||||
|
||||
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
||||
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
|
||||
|
||||
@ -130,6 +142,55 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
];
|
||||
}
|
||||
|
||||
private function isMetadataOnlyPolicyType(string $policyType): bool
|
||||
{
|
||||
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
|
||||
if (($type['type'] ?? null) === $policyType) {
|
||||
return ($type['backup'] ?? null) === 'metadata-only';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function metadataOnlySelect(string $policyType): array
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$allowedSelect = $contract['allowed_select'] ?? [];
|
||||
|
||||
if (! is_array($allowedSelect)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$allowedSelect,
|
||||
static fn (mixed $key) => is_string($key) && $key !== '@odata.type'
|
||||
));
|
||||
}
|
||||
|
||||
private function filterMetadataOnlyPayload(string $policyType, array $payload): array
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$allowedSelect = $contract['allowed_select'] ?? [];
|
||||
|
||||
if (! is_array($allowedSelect) || $allowedSelect === []) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
|
||||
foreach ($allowedSelect as $key) {
|
||||
if (is_string($key) && array_key_exists($key, $payload)) {
|
||||
$filtered[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate settings catalog policies with configuration settings subresource.
|
||||
*
|
||||
|
||||
@ -794,11 +794,32 @@ private function applyScopeTagMapping(array $payload, array $scopeTagMapping): a
|
||||
*/
|
||||
private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array
|
||||
{
|
||||
if ($scopeTagIds === null || $scopeTagMapping === []) {
|
||||
if ($scopeTagIds === null) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$payload['roleScopeTagIds'] = array_values($scopeTagIds);
|
||||
$mapped = [];
|
||||
|
||||
foreach ($scopeTagIds as $id) {
|
||||
if (! is_string($id) && ! is_int($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stringId = (string) $id;
|
||||
|
||||
if ($stringId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[] = $scopeTagMapping[$stringId] ?? $stringId;
|
||||
}
|
||||
|
||||
if ($mapped === []) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$payload['roleScopeTagIds'] = array_values(array_unique($mapped));
|
||||
unset($payload['RoleScopeTagIds']);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@ -319,15 +319,54 @@
|
||||
],
|
||||
'mobileApp' => [
|
||||
'resource' => 'deviceAppManagement/mobileApps',
|
||||
'allowed_select' => ['id', 'displayName', 'publisher', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
|
||||
'allowed_select' => ['id', 'displayName', 'publisher', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.mobileApp',
|
||||
'#microsoft.graph.androidLobApp',
|
||||
'#microsoft.graph.androidStoreApp',
|
||||
'#microsoft.graph.androidManagedStoreApp',
|
||||
'#microsoft.graph.iosLobApp',
|
||||
'#microsoft.graph.iosStoreApp',
|
||||
'#microsoft.graph.iosVppApp',
|
||||
'#microsoft.graph.winGetApp',
|
||||
'#microsoft.graph.macOSLobApp',
|
||||
'#microsoft.graph.macOSMicrosoftEdgeApp',
|
||||
'#microsoft.graph.macOSMicrosoftDefenderApp',
|
||||
'#microsoft.graph.macOSDmgApp',
|
||||
'#microsoft.graph.macOSPkgApp',
|
||||
'#microsoft.graph.macOsVppApp',
|
||||
'#microsoft.graph.macOSWebClip',
|
||||
'#microsoft.graph.managedAndroidLobApp',
|
||||
'#microsoft.graph.managedAndroidStoreApp',
|
||||
'#microsoft.graph.managedIOSLobApp',
|
||||
'#microsoft.graph.managedIOSStoreApp',
|
||||
'#microsoft.graph.microsoftStoreForBusinessApp',
|
||||
'#microsoft.graph.officeSuiteApp',
|
||||
'#microsoft.graph.macOSOfficeSuiteApp',
|
||||
'#microsoft.graph.webApp',
|
||||
'#microsoft.graph.windowsWebApp',
|
||||
'#microsoft.graph.windowsAppX',
|
||||
'#microsoft.graph.windowsUniversalAppX',
|
||||
'#microsoft.graph.windowsMicrosoftEdgeApp',
|
||||
'#microsoft.graph.windowsMobileMSI',
|
||||
'#microsoft.graph.windowsPhone81AppXBundle',
|
||||
'#microsoft.graph.windowsPhone81AppX',
|
||||
'#microsoft.graph.windowsPhone81StoreApp',
|
||||
'#microsoft.graph.windowsPhoneXAP',
|
||||
'#microsoft.graph.windowsStoreApp',
|
||||
'#microsoft.graph.win32LobApp',
|
||||
'#microsoft.graph.win32CatalogApp',
|
||||
'#microsoft.graph.iOSiPadOSWebClip',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceAppManagement/mobileApps/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceAppManagement/mobileApps/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'mobileAppAssignments',
|
||||
],
|
||||
'assignmentFilter' => [
|
||||
'resource' => 'deviceManagement/assignmentFilters',
|
||||
|
||||
26
specs/008-apps-app-management/plan.md
Normal file
26
specs/008-apps-app-management/plan.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Implementation Plan: Apps (008)
|
||||
|
||||
**Branch**: `feat/008-apps-app-management`
|
||||
**Date**: 2025-12-29
|
||||
**Spec Source**: [spec.md](./spec.md)
|
||||
|
||||
## Summary
|
||||
Make `mobileApp` reliable in the existing Policy/Backup/Restore flows by:
|
||||
|
||||
- Extending Graph contract registry with app assignment endpoints and a realistic `@odata.type` family.
|
||||
- Enforcing metadata-only snapshots during capture (contract-driven filtering).
|
||||
- Adding targeted Pest tests for contracts + snapshot behavior.
|
||||
|
||||
## Execution Steps
|
||||
1. Update `config/graph_contracts.php` for `mobileApp`:
|
||||
- Add assignments list + assign action endpoints and payload key.
|
||||
- Expand `type_family` using known app types.
|
||||
2. Update `app/Services/Intune/PolicySnapshotService.php`:
|
||||
- For metadata-only types, request/select and persist only metadata keys (even if Graph falls back).
|
||||
3. Add/extend tests:
|
||||
- `tests/Unit/GraphContractRegistryActualDataTest.php` for `mobileApp` contract coverage.
|
||||
- `tests/Unit/PolicySnapshotServiceTest.php` for metadata-only filtering behavior.
|
||||
4. Run formatting + tests:
|
||||
- `./vendor/bin/pint --dirty`
|
||||
- `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php`
|
||||
|
||||
59
specs/008-apps-app-management/spec.md
Normal file
59
specs/008-apps-app-management/spec.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Feature Specification: Apps (Mobile Apps) Metadata-Only + Assignments
|
||||
|
||||
**Feature Branch**: `feat/008-apps-app-management`
|
||||
**Created**: 2025-12-29
|
||||
**Status**: Draft
|
||||
**Input**: `.specify/spec.md` (mobileApp scope) + `references/IntuneManagement-master/Documentation/AppTypes.json` (known app @odata.type values).
|
||||
|
||||
## Overview
|
||||
Add reliable **mobile app** (`mobileApp`) coverage for inventory, backup/version capture, and restore:
|
||||
|
||||
- Capture **metadata-only** snapshots for Intune apps (no content/binary workflows).
|
||||
- Capture and restore **assignments** using the Intune `/assignments` and `/assign` endpoints.
|
||||
- Accept common **derived `@odata.type`** values for apps to avoid false `odata_mismatch` failures.
|
||||
|
||||
## In Scope
|
||||
- Policy type: `mobileApp` (`deviceAppManagement/mobileApps`) in existing Policy/Backup/Restore flows.
|
||||
- Backup/version capture stores only metadata fields + `@odata.type` (contract-driven).
|
||||
- Restore updates metadata fields on existing apps and reapplies assignments (with group + assignment filter mapping).
|
||||
- UI continues to use the existing Policies/Versions/Backup/Restore Filament experience.
|
||||
|
||||
## Out of Scope (v1)
|
||||
- Downloading or re-uploading app binaries/content (Win32 content versions, files, etc.).
|
||||
- Creating complex missing apps from scratch (where Graph requires app-type-specific required fields).
|
||||
- App relationships (dependencies/supersedence) beyond what is already present in metadata.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Inventory and detail view for apps (Priority: P1)
|
||||
As an admin, I can see Intune apps in the Policies list and inspect app metadata and assignments safely.
|
||||
|
||||
**Independent Test**: Sync policies; open Policies list filtered to “Applications”; open an app and verify metadata is readable and assignments are shown (when captured).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given apps exist in Intune, when the admin syncs and opens Policies, then apps appear under type `Applications (Metadata only)`.
|
||||
2. Given an app is a derived type (e.g. Win32, iOS VPP), when viewed/restored, then it is considered in-family for `mobileApp` (no `odata_mismatch`).
|
||||
|
||||
### User Story 2 - Backup capture for apps (Priority: P1)
|
||||
As an admin, I can capture backups/versions of apps with metadata-only payloads and optional assignments.
|
||||
|
||||
**Independent Test**: Create a backup with assignments enabled including at least one app; confirm the stored payload is metadata-only and assignments are captured.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given app backup is configured as metadata-only, when a snapshot is captured, then only whitelisted metadata keys are persisted.
|
||||
2. Given assignments are enabled, when capturing, then assignments are fetched via the configured assignments endpoint and stored alongside the snapshot.
|
||||
|
||||
### User Story 3 - Restore assignments for apps (Priority: P1)
|
||||
As an admin, I can restore app assignments (and metadata where supported) using group mapping with clear skip/failure reasons.
|
||||
|
||||
**Independent Test**: Restore an app backup into a tenant where group IDs differ; verify assignments are created/skipped with the expected outcomes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given an app exists in the target tenant, when restore executes, then metadata fields are patched and assignments are applied via `/assign`.
|
||||
2. Given required group mappings are missing, when restore executes, then assignments are skipped with a human readable reason per assignment.
|
||||
3. Given the app does not exist (404), when restore executes, then the item fails with a clear reason (no attempt to create complex apps from metadata-only snapshots).
|
||||
|
||||
## Notes
|
||||
- `@odata.type` matching uses `config/graph_contracts.php` as the safety gate for restore execution.
|
||||
- App type-family values are sourced from `references/IntuneManagement-master/Documentation/AppTypes.json` and kept conservative.
|
||||
|
||||
17
specs/008-apps-app-management/tasks.md
Normal file
17
specs/008-apps-app-management/tasks.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Tasks: Apps (Mobile Apps) Metadata-Only + Assignments (008)
|
||||
|
||||
**Branch**: `feat/008-apps-app-management` | **Date**: 2025-12-29
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||
|
||||
## Phase 1: Contracts and Safety Gates
|
||||
- [ ] T001 Expand `mobileApp` Graph contract in `config/graph_contracts.php` (assignments endpoints + payload key + type family).
|
||||
|
||||
## Phase 2: Snapshot Capture (Metadata-Only)
|
||||
- [ ] T002 Enforce metadata-only snapshot capture for apps in `app/Services/Intune/PolicySnapshotService.php`.
|
||||
|
||||
## Phase 3: Tests and Verification
|
||||
- [ ] T003 Add contract coverage tests in `tests/Unit/GraphContractRegistryActualDataTest.php`.
|
||||
- [ ] T004 Add snapshot filtering tests in `tests/Unit/PolicySnapshotServiceTest.php`.
|
||||
- [ ] T005 Run tests: `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php`
|
||||
- [ ] T006 Run Pint: `./vendor/bin/pint --dirty`
|
||||
|
||||
@ -283,6 +283,107 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
]);
|
||||
});
|
||||
|
||||
test('restore execution applies scope tags even without foundation mapping', function () {
|
||||
$client = new class implements GraphClientInterface
|
||||
{
|
||||
public array $applied = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applied[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-scope-tags',
|
||||
'name' => 'Tenant Scope Tags',
|
||||
'status' => 'active',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'app-1',
|
||||
'policy_type' => 'mobileApp',
|
||||
'display_name' => 'Mozilla Firefox',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.winGetApp',
|
||||
'displayName' => 'Mozilla Firefox',
|
||||
'roleScopeTagIds' => ['0', 'tag-1'],
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($client->applied)->toHaveCount(1);
|
||||
expect($client->applied[0]['policyType'])->toBe('mobileApp');
|
||||
expect($client->applied[0]['policyId'])->toBe('app-1');
|
||||
expect($client->applied[0]['payload']['roleScopeTagIds'])->toBe(['0', 'tag-1']);
|
||||
});
|
||||
|
||||
test('restore execution creates an autopilot profile when missing', function () {
|
||||
$graphClient = new class implements GraphClientInterface
|
||||
{
|
||||
|
||||
@ -102,6 +102,21 @@
|
||||
->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('exposes mobile app assignment endpoints and type family', function () {
|
||||
$contract = $this->registry->get('mobileApp');
|
||||
|
||||
expect($contract)->not->toBeEmpty();
|
||||
expect($contract['allowed_select'] ?? [])->toContain('roleScopeTagIds');
|
||||
expect($contract['assignments_list_path'] ?? null)
|
||||
->toBe('/deviceAppManagement/mobileApps/{id}/assignments');
|
||||
expect($contract['assignments_create_path'] ?? null)
|
||||
->toBe('/deviceAppManagement/mobileApps/{id}/assign');
|
||||
expect($contract['assignments_payload_key'] ?? null)
|
||||
->toBe('mobileAppAssignments');
|
||||
expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.win32LobApp'))->toBeTrue();
|
||||
expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('omits role scope tags from assignment filter selects', function () {
|
||||
$contract = $this->registry->get('assignmentFilter');
|
||||
|
||||
|
||||
@ -24,6 +24,23 @@ public function getPolicy(string $policyType, string $policyId, array $options =
|
||||
{
|
||||
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
|
||||
|
||||
if ($policyType === 'mobileApp') {
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
'displayName' => 'Contoso Portal',
|
||||
'publisher' => 'Contoso',
|
||||
'description' => 'Company Portal',
|
||||
'@odata.type' => '#microsoft.graph.win32LobApp',
|
||||
'createdDateTime' => '2025-01-01T00:00:00Z',
|
||||
'lastModifiedDateTime' => '2025-01-02T00:00:00Z',
|
||||
'roleScopeTagIds' => ['0', 'tag-1', 'tag-2'],
|
||||
'installCommandLine' => 'setup.exe /quiet',
|
||||
'largeIcon' => ['type' => 'image/png', 'value' => '...'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
@ -107,3 +124,48 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requests[0][3]['expand'] ?? null)
|
||||
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('filters mobile app snapshots to metadata-only keys', function () {
|
||||
$client = new PolicySnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-apps',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'app-123',
|
||||
'policy_type' => 'mobileApp',
|
||||
'display_name' => 'Contoso Portal',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result['payload'])->toHaveKeys([
|
||||
'id',
|
||||
'displayName',
|
||||
'publisher',
|
||||
'description',
|
||||
'@odata.type',
|
||||
'createdDateTime',
|
||||
'lastModifiedDateTime',
|
||||
'roleScopeTagIds',
|
||||
]);
|
||||
expect($result['payload']['roleScopeTagIds'])->toBe(['0', 'tag-1', 'tag-2']);
|
||||
expect($result['payload'])->not->toHaveKey('installCommandLine');
|
||||
expect($result['payload'])->not->toHaveKey('largeIcon');
|
||||
expect($client->requests[0][0])->toBe('getPolicy');
|
||||
expect($client->requests[0][1])->toBe('mobileApp');
|
||||
expect($client->requests[0][2])->toBe('app-123');
|
||||
expect($client->requests[0][3]['select'] ?? null)->toBeArray();
|
||||
expect($client->requests[0][3]['select'])->toContain('displayName');
|
||||
expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds');
|
||||
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user