Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.43% covered (warning)
88.43%
214 / 242
35.29% covered (danger)
35.29%
6 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
IdentifyMethodsPolicyValue
88.43% covered (warning)
88.43%
214 / 242
35.29% covered (danger)
35.29%
6 / 17
142.30
0.00% covered (danger)
0.00%
0 / 1
 normalize
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
12
 prepareInput
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 prepareInputFromArrayPayload
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 normalizeFactors
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 normalizeCatalogFactors
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 mergeFactorsWithCatalog
69.05% covered (warning)
69.05%
29 / 42
0.00% covered (danger)
0.00%
0 / 1
46.05
 buildCatalogFactor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 mergeFactorWithCatalog
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
15.20
 mergeSignatureMethods
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 normalizeFactorEntry
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
11.08
 normalizeSignatureMethods
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
8.09
 normalizeSignatureMethodConfig
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
13.03
 enrichFriendlyNames
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 canonicalizeFactorEntries
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 extractFactors
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 resolveGlobalCanCreateAccount
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 normalizeMinimumTotalVerifiedFactors
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Policy\Provider\IdentifyMethods;
10
11use OCA\Libresign\Enum\IdentifyMethodRequirement;
12use OCA\Libresign\Service\IdentifyMethodService;
13
14final class IdentifyMethodsPolicyValue {
15    /**
16     * @return array{factors: list<array<string, mixed>>, can_create_account?: bool}
17     */
18    public static function normalize(mixed $rawValue, ?IdentifyMethodService $identifyMethodService = null): array {
19        $preparedInput = self::prepareInput($rawValue);
20        $catalogFactors = self::normalizeCatalogFactors($identifyMethodService);
21        // Use service-provided defaults when policy is empty and service is available
22        $isEmpty = $preparedInput['factors'] === null || (is_array($preparedInput['factors']) && count($preparedInput['factors']) === 0);
23        if ($isEmpty) {
24            $defaultFactors = [];
25            if ($identifyMethodService !== null) {
26                $defaultFactors = $identifyMethodService->getDefaultIdentifyMethodsPolicy();
27                if ($defaultFactors === []) {
28                    $friendlyNames = $identifyMethodService->getFriendlyNamesMap();
29                    foreach ($friendlyNames as $name => $friendlyName) {
30                        $defaultFactors[] = [
31                            'name' => $name,
32                            'enabled' => true,
33                            'signatureMethods' => [],
34                            'friendly_name' => $friendlyName,
35                        ];
36                    }
37                }
38            }
39            $normalization = self::normalizeFactors($defaultFactors, null);
40            $normalized = $normalization['factors'];
41            if ($catalogFactors !== []) {
42                $normalized = self::mergeFactorsWithCatalog($normalized, $catalogFactors);
43            }
44
45            if ($identifyMethodService !== null) {
46                $normalized = self::enrichFriendlyNames($normalized, $identifyMethodService->getFriendlyNamesMap());
47            }
48            $normalized = self::canonicalizeFactorEntries($normalized);
49
50            return [
51                'factors' => $normalized,
52            ];
53        }
54        $normalization = self::normalizeFactors(
55            $preparedInput['factors'],
56            $preparedInput['sharedMinimumTotalVerifiedFactors'],
57        );
58        $normalized = $normalization['factors'];
59        $globalCanCreateAccount = $preparedInput['globalCanCreateAccount'];
60        if ($catalogFactors !== []) {
61            $normalized = self::mergeFactorsWithCatalog($normalized, $catalogFactors, false);
62        }
63
64        if ($identifyMethodService !== null) {
65            $normalized = self::enrichFriendlyNames($normalized, $identifyMethodService->getFriendlyNamesMap());
66        }
67        $normalized = self::canonicalizeFactorEntries($normalized);
68
69        $payload = [
70            'factors' => $normalized,
71        ];
72        if ($globalCanCreateAccount !== null) {
73            $payload['can_create_account'] = $globalCanCreateAccount;
74        }
75
76        return $payload;
77    }
78
79    /**
80     * @return array{factors: list<mixed>|null, sharedMinimumTotalVerifiedFactors: ?int, globalCanCreateAccount: ?bool}
81     */
82    private static function prepareInput(mixed $rawValue): array {
83        $globalCanCreateAccount = null;
84        $sharedMinimumTotalVerifiedFactors = null;
85        $factors = null;
86
87        if (is_string($rawValue)) {
88            $decoded = json_decode($rawValue, true);
89            if (is_array($decoded)) {
90                return self::prepareInputFromArrayPayload($decoded);
91            }
92        }
93
94        if (is_array($rawValue)) {
95            return self::prepareInputFromArrayPayload($rawValue);
96        }
97
98        return [
99            'factors' => $factors,
100            'sharedMinimumTotalVerifiedFactors' => $sharedMinimumTotalVerifiedFactors,
101            'globalCanCreateAccount' => $globalCanCreateAccount,
102        ];
103    }
104
105    /**
106     * @param array<string, mixed>|list<mixed> $payload
107     * @return array{factors: list<mixed>|null, sharedMinimumTotalVerifiedFactors: ?int, globalCanCreateAccount: ?bool}
108     */
109    private static function prepareInputFromArrayPayload(array $payload): array {
110        if (array_is_list($payload)) {
111            return [
112                'factors' => array_values($payload),
113                'sharedMinimumTotalVerifiedFactors' => null,
114                'globalCanCreateAccount' => null,
115            ];
116        }
117
118        $factors = null;
119        $sharedMinimumTotalVerifiedFactors = null;
120        if (isset($payload['factors']) && is_array($payload['factors'])) {
121            $factors = array_values($payload['factors']);
122            $sharedMinimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($payload['minimumTotalVerifiedFactors'] ?? null);
123        }
124
125        $globalCanCreateAccount = null;
126        if (array_key_exists('can_create_account', $payload)) {
127            $globalCanCreateAccount = (bool)$payload['can_create_account'];
128        }
129
130        return [
131            'factors' => $factors,
132            'sharedMinimumTotalVerifiedFactors' => $sharedMinimumTotalVerifiedFactors,
133            'globalCanCreateAccount' => $globalCanCreateAccount,
134        ];
135    }
136
137    /**
138     * @param list<mixed> $rawFactors
139     * @return array{factors: list<array<string, mixed>>}
140     */
141    private static function normalizeFactors(
142        array $rawFactors,
143        ?int $sharedMinimumTotalVerifiedFactors,
144    ): array {
145        $normalized = [];
146        foreach ($rawFactors as $entry) {
147            if (!is_array($entry)) {
148                continue;
149            }
150
151            $normalizedEntry = self::normalizeFactorEntry($entry, $sharedMinimumTotalVerifiedFactors);
152            if ($normalizedEntry === null) {
153                continue;
154            }
155
156            $normalized[] = $normalizedEntry;
157        }
158
159        return [
160            'factors' => $normalized,
161        ];
162    }
163
164    /**
165     * @return list<array<string, mixed>>
166     */
167    private static function normalizeCatalogFactors(?IdentifyMethodService $identifyMethodService): array {
168        if ($identifyMethodService === null) {
169            return [];
170        }
171
172        return self::extractFactors(self::normalize([
173            'factors' => $identifyMethodService->getIdentifyMethodsCatalogSettings(),
174        ]));
175    }
176
177    /**
178     * @param list<array<string, mixed>> $policyFactors
179     * @param list<array<string, mixed>> $catalogFactors
180     * @param bool $addMissingFromCatalog When true (default), factors present in the catalog but absent
181     *                                    from the policy are appended as disabled entries (used for the
182     *                                    empty-policy path).  When false, only the factors already in
183     *                                    the policy are kept (in policy order) and each is enriched
184     *                                    with catalog metadata such as labels and friendly names.
185     * @return list<array<string, mixed>>
186     */
187    private static function mergeFactorsWithCatalog(array $policyFactors, array $catalogFactors, bool $addMissingFromCatalog = true): array {
188        if ($catalogFactors === []) {
189            return $policyFactors;
190        }
191
192        if (!$addMissingFromCatalog) {
193            // Enrich-only mode: preserve policy factors in policy order, enrich each from catalog.
194            $catalogFactorsByName = [];
195            foreach ($catalogFactors as $catalogFactor) {
196                if (!isset($catalogFactor['name']) || !is_string($catalogFactor['name']) || $catalogFactor['name'] === '') {
197                    continue;
198                }
199                $catalogFactorsByName[$catalogFactor['name']] = $catalogFactor;
200            }
201
202            $mergedFactors = [];
203            foreach ($policyFactors as $policyFactor) {
204                if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') {
205                    $mergedFactors[] = $policyFactor;
206                    continue;
207                }
208                $catalogFactor = $catalogFactorsByName[$policyFactor['name']] ?? null;
209                if ($catalogFactor === null) {
210                    $mergedFactors[] = $policyFactor;
211                    continue;
212                }
213                $mergedFactors[] = self::mergeFactorWithCatalog($policyFactor, $catalogFactor);
214            }
215            return $mergedFactors;
216        }
217
218        // Full merge mode: catalog-ordered, catalog factors missing from the policy are added as disabled.
219        $policyFactorsByName = [];
220        foreach ($policyFactors as $policyFactor) {
221            if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') {
222                continue;
223            }
224
225            $policyFactorsByName[$policyFactor['name']] = $policyFactor;
226        }
227
228        $catalogFactorNames = [];
229        $mergedFactors = [];
230        foreach ($catalogFactors as $catalogFactor) {
231            if (!isset($catalogFactor['name']) || !is_string($catalogFactor['name']) || $catalogFactor['name'] === '') {
232                continue;
233            }
234
235            $catalogFactorNames[$catalogFactor['name']] = true;
236            $policyFactor = $policyFactorsByName[$catalogFactor['name']] ?? null;
237            if ($policyFactor === null) {
238                $mergedFactors[] = self::buildCatalogFactor($catalogFactor);
239                continue;
240            }
241
242            $mergedFactors[] = self::mergeFactorWithCatalog($policyFactor, $catalogFactor);
243        }
244
245        foreach ($policyFactors as $policyFactor) {
246            if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') {
247                $mergedFactors[] = $policyFactor;
248                continue;
249            }
250
251            if (!isset($catalogFactorNames[$policyFactor['name']])) {
252                $mergedFactors[] = $policyFactor;
253            }
254        }
255
256        return $mergedFactors;
257    }
258
259    /**
260     * @param array<string, mixed> $catalogFactor
261     * @return array<string, mixed>
262     */
263    private static function buildCatalogFactor(array $catalogFactor): array {
264        $catalogFactor['enabled'] = false;
265
266        return $catalogFactor;
267    }
268
269    /**
270     * @param array<string, mixed> $policyFactor
271     * @param array<string, mixed> $catalogFactor
272     * @return array<string, mixed>
273     */
274    private static function mergeFactorWithCatalog(array $policyFactor, array $catalogFactor): array {
275        $mergedFactor = $policyFactor;
276
277        if (!isset($mergedFactor['friendly_name']) && isset($catalogFactor['friendly_name'])) {
278            $mergedFactor['friendly_name'] = $catalogFactor['friendly_name'];
279        }
280
281        if (!isset($mergedFactor['requirement']) && isset($catalogFactor['requirement'])) {
282            $mergedFactor['requirement'] = $catalogFactor['requirement'];
283        }
284
285        if (!isset($mergedFactor['minimumTotalVerifiedFactors']) && isset($catalogFactor['minimumTotalVerifiedFactors'])) {
286            $mergedFactor['minimumTotalVerifiedFactors'] = $catalogFactor['minimumTotalVerifiedFactors'];
287        }
288
289        if (!isset($mergedFactor['signatureMethodEnabled']) && isset($catalogFactor['signatureMethodEnabled'])) {
290            $mergedFactor['signatureMethodEnabled'] = $catalogFactor['signatureMethodEnabled'];
291        }
292
293        $catalogSignatureMethods = isset($catalogFactor['signatureMethods']) && is_array($catalogFactor['signatureMethods'])
294            ? $catalogFactor['signatureMethods']
295            : [];
296        $policySignatureMethods = isset($policyFactor['signatureMethods']) && is_array($policyFactor['signatureMethods'])
297            ? $policyFactor['signatureMethods']
298            : [];
299        $mergedFactor['signatureMethods'] = self::mergeSignatureMethods($catalogSignatureMethods, $policySignatureMethods);
300
301        return $mergedFactor;
302    }
303
304    /**
305     * @param array<string, array<string, mixed>> $catalogSignatureMethods
306     * @param array<string, array<string, mixed>> $policySignatureMethods
307     * @return array<string, array<string, mixed>>
308     */
309    private static function mergeSignatureMethods(array $catalogSignatureMethods, array $policySignatureMethods): array {
310        $mergedSignatureMethods = [];
311
312        if ($policySignatureMethods !== []) {
313            // Policy explicitly declares which signature methods are active.
314            // Enrich each declared method with catalog metadata (e.g. label) but do NOT
315            // add catalog methods that the policy omitted.
316            foreach (array_keys($policySignatureMethods) as $signatureMethodName) {
317                $mergedSignatureMethods[$signatureMethodName] = array_merge(
318                    $catalogSignatureMethods[$signatureMethodName] ?? [],
319                    $policySignatureMethods[$signatureMethodName],
320                );
321            }
322            return $mergedSignatureMethods;
323        }
324
325        // No explicit signature methods in policy â€“ expose all catalog methods.
326        foreach ($catalogSignatureMethods as $signatureMethodName => $catalogMethod) {
327            $mergedSignatureMethods[$signatureMethodName] = $catalogMethod;
328        }
329
330        return $mergedSignatureMethods;
331    }
332
333    /**
334     * @param array<string, mixed> $entry
335     * @return ?array<string, mixed>
336     */
337    private static function normalizeFactorEntry(
338        array $entry,
339        ?int $sharedMinimumTotalVerifiedFactors,
340    ): ?array {
341        $name = isset($entry['name']) && is_string($entry['name'])
342            ? trim($entry['name'])
343            : '';
344        if ($name === '') {
345            return null;
346        }
347
348        $signatureMethods = self::normalizeSignatureMethods($entry);
349        $normalizedEntry = [
350            'name' => $name,
351            'enabled' => array_key_exists('enabled', $entry) ? (bool)$entry['enabled'] : true,
352            'signatureMethods' => $signatureMethods,
353        ];
354
355        if (isset($entry['friendly_name']) && is_string($entry['friendly_name'])) {
356            $normalizedEntry['friendly_name'] = $entry['friendly_name'];
357        }
358
359        $minimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($entry['minimumTotalVerifiedFactors'] ?? null)
360            ?? $sharedMinimumTotalVerifiedFactors;
361        if ($minimumTotalVerifiedFactors !== null) {
362            $normalizedEntry['minimumTotalVerifiedFactors'] = $minimumTotalVerifiedFactors;
363        }
364
365        $requirement = IdentifyMethodRequirement::tryFrom((string)($entry['requirement'] ?? ''));
366        if ($requirement !== null) {
367            $normalizedEntry['requirement'] = $requirement->value;
368        }
369
370        if (isset($entry['signatureMethodEnabled']) && is_string($entry['signatureMethodEnabled'])) {
371            $normalizedEntry['signatureMethodEnabled'] = $entry['signatureMethodEnabled'];
372        }
373
374        return $normalizedEntry;
375    }
376
377    /**
378     * @param array<string, mixed> $entry
379     * @return array<string, array<string, mixed>>
380     */
381    private static function normalizeSignatureMethods(array $entry): array {
382        $signatureMethods = [];
383        if (isset($entry['signatureMethods']) && is_array($entry['signatureMethods']) && !array_is_list($entry['signatureMethods'])) {
384            foreach ($entry['signatureMethods'] as $signatureMethodName => $signatureMethodConfig) {
385                if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') {
386                    continue;
387                }
388                $normalizedSignatureMethod = self::normalizeSignatureMethodConfig($signatureMethodConfig);
389                if ($normalizedSignatureMethod !== null) {
390                    $signatureMethods[$signatureMethodName] = $normalizedSignatureMethod;
391                }
392            }
393        }
394
395        return $signatureMethods;
396    }
397
398    /**
399     * @return ?array<string, mixed>
400     */
401    private static function normalizeSignatureMethodConfig(mixed $signatureMethodConfig): ?array {
402        if (is_string($signatureMethodConfig)) {
403            return [
404                'enabled' => false,
405                'label' => $signatureMethodConfig,
406            ];
407        }
408
409        if (!is_array($signatureMethodConfig)) {
410            return null;
411        }
412
413        $normalizedSignatureMethod = [];
414        $normalizedSignatureMethod = $signatureMethodConfig;
415        if (array_key_exists('enabled', $signatureMethodConfig)) {
416            $normalizedSignatureMethod['enabled'] = (bool)$signatureMethodConfig['enabled'];
417        }
418
419        if (isset($signatureMethodConfig['name']) && is_string($signatureMethodConfig['name']) && trim($signatureMethodConfig['name']) !== '') {
420            $normalizedSignatureMethod['name'] = trim($signatureMethodConfig['name']);
421        }
422
423        if (isset($signatureMethodConfig['label']) && is_string($signatureMethodConfig['label']) && trim($signatureMethodConfig['label']) !== '') {
424            $normalizedSignatureMethod['label'] = $signatureMethodConfig['label'];
425        }
426
427        if (!isset($normalizedSignatureMethod['name']) && isset($normalizedSignatureMethod['label']) && is_string($normalizedSignatureMethod['label'])) {
428            $normalizedSignatureMethod['name'] = $normalizedSignatureMethod['label'];
429        }
430
431        return $normalizedSignatureMethod;
432    }
433
434    /**
435     * @param list<array<string, mixed>> $normalized
436     * @param array<string, string> $friendlyNames
437     * @return list<array<string, mixed>>
438     */
439    private static function enrichFriendlyNames(array $normalized, array $friendlyNames): array {
440        foreach ($normalized as &$entry) {
441            if (!isset($entry['friendly_name']) && isset($entry['name'], $friendlyNames[$entry['name']])) {
442                $entry['friendly_name'] = $friendlyNames[$entry['name']];
443            }
444        }
445        unset($entry);
446
447        return $normalized;
448    }
449
450    /**
451     * @param list<array<string, mixed>> $normalized
452     * @return list<array<string, mixed>>
453     */
454    private static function canonicalizeFactorEntries(array $normalized): array {
455        $canonicalized = [];
456        foreach ($normalized as $entry) {
457            $canonicalEntry = [];
458            foreach (['name', 'enabled', 'signatureMethods', 'friendly_name', 'minimumTotalVerifiedFactors', 'requirement', 'signatureMethodEnabled'] as $key) {
459                if (array_key_exists($key, $entry)) {
460                    $canonicalEntry[$key] = $entry[$key];
461                    unset($entry[$key]);
462                }
463            }
464
465            foreach ($entry as $key => $value) {
466                $canonicalEntry[$key] = $value;
467            }
468
469            $canonicalized[] = $canonicalEntry;
470        }
471
472        return $canonicalized;
473    }
474
475    /**
476     * @return list<array<string, mixed>>
477     */
478    public static function extractFactors(array $normalizedPayload): array {
479        if (isset($normalizedPayload['factors']) && is_array($normalizedPayload['factors'])) {
480            return array_values(array_filter($normalizedPayload['factors'], static fn (mixed $entry): bool => is_array($entry)));
481        }
482
483        return [];
484    }
485
486    public static function resolveGlobalCanCreateAccount(array $normalizedPayload): ?bool {
487        if (array_key_exists('can_create_account', $normalizedPayload)) {
488            return (bool)$normalizedPayload['can_create_account'];
489        }
490
491        return null;
492    }
493
494    private static function normalizeMinimumTotalVerifiedFactors(mixed $value): ?int {
495        if (!is_numeric($value)) {
496            return null;
497        }
498
499        $normalized = (int)$value;
500        if ($normalized < 1) {
501            return null;
502        }
503
504        return $normalized;
505    }
506}