Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
90 / 93
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
RuntimeRequirementValidator
96.77% covered (success)
96.77%
90 / 93
90.91% covered (success)
90.91%
10 / 11
39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getMethodsByName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 summarizeVerificationState
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
7
 validateRequiredFactorsCompleted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateMinimumFactorsCompleted
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 resolveMinimumFromSettingsList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveMinimumTotalVerifiedFactors
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 resolveMinimumFromSettings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resolveMinimumCandidate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 resolveMinimumTotalVerifiedFactorsFromPolicySnapshot
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
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\IdentifyMethod;
10
11use OCA\Libresign\Db\FileMapper;
12use OCA\Libresign\Db\SignRequest;
13use OCA\Libresign\Enum\IdentifyMethodRequirement;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Service\IdentifyMethodService;
16use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy;
17use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicyValue;
18use OCP\IL10N;
19
20class RuntimeRequirementValidator {
21    public function __construct(
22        private IdentifyMethodService $identifyMethodService,
23        private FileMapper $fileMapper,
24        private IL10N $l10n,
25    ) {
26    }
27
28    public function validate(SignRequest $signRequest): void {
29        $methodsByName = $this->getMethodsByName($signRequest);
30        if (empty($methodsByName)) {
31            return;
32        }
33
34        $summary = $this->summarizeVerificationState($methodsByName);
35        $this->validateRequiredFactorsCompleted($summary);
36
37        $minimumTotalVerifiedFactors = $this->resolveMinimumTotalVerifiedFactors(
38            $signRequest,
39            $summary['methodNames'],
40            $summary['hasOptionalFactor'],
41        );
42        if ($minimumTotalVerifiedFactors === null) {
43            return;
44        }
45
46        $this->validateMinimumFactorsCompleted($summary, $minimumTotalVerifiedFactors);
47    }
48
49    /**
50     * @return array<string, array<IIdentifyMethod>>
51     */
52    private function getMethodsByName(SignRequest $signRequest): array {
53        if (!$signRequest->getId()) {
54            return [];
55        }
56
57        return $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
58    }
59
60    /**
61     * @param array<string, list<IIdentifyMethod>> $methodsByName
62     * @return array{
63     *     requiredFactors: int,
64     *     identifiedRequiredFactors: int,
65     *     identifiedFactors: int,
66     *     hasOptionalFactor: bool,
67     *     methodNames: list<string>
68     * }
69     */
70    private function summarizeVerificationState(array $methodsByName): array {
71
72        $requiredFactors = 0;
73        $identifiedRequiredFactors = 0;
74        $identifiedFactors = 0;
75        $hasOptionalFactor = false;
76        $methodNames = [];
77
78        foreach ($methodsByName as $methods) {
79            foreach ($methods as $identifyMethod) {
80                $entity = $identifyMethod->getEntity();
81                $methodName = $entity->getIdentifierKey();
82                $methodNames[$methodName] = true;
83
84                $isRequired = $entity->getRequirement() === IdentifyMethodRequirement::REQUIRED->value;
85                $isIdentified = $entity->getIdentifiedAtDate() !== null;
86
87                if (!$isRequired) {
88                    $hasOptionalFactor = true;
89                }
90
91                if ($isRequired) {
92                    $requiredFactors++;
93                    if ($isIdentified) {
94                        $identifiedRequiredFactors++;
95                    }
96                }
97
98                if ($isIdentified) {
99                    $identifiedFactors++;
100                }
101            }
102        }
103
104        return [
105            'requiredFactors' => $requiredFactors,
106            'identifiedRequiredFactors' => $identifiedRequiredFactors,
107            'identifiedFactors' => $identifiedFactors,
108            'hasOptionalFactor' => $hasOptionalFactor,
109            'methodNames' => array_keys($methodNames),
110        ];
111    }
112
113    /**
114     * Pure logic: validates that all required factors are identified.
115     * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list<string>} $summary
116     * @throws LibresignException
117     */
118    public function validateRequiredFactorsCompleted(array $summary): void {
119        if ($summary['identifiedRequiredFactors'] < $summary['requiredFactors']) {
120            // TRANSLATORS: Error shown when a signer tries to sign before completing every identification factor marked as required, such as email, password, or document validation.
121            throw new LibresignException($this->l10n->t('You need to complete all required identification factors before signing.'));
122        }
123    }
124
125    /**
126     * Pure logic: validates that total identified factors meet minimum requirement.
127     * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list<string>} $summary
128     * @param int $minimumTotalVerifiedFactors
129     * @throws LibresignException
130     */
131    public function validateMinimumFactorsCompleted(array $summary, int $minimumTotalVerifiedFactors): void {
132        $requiredVerifiedFactors = max($summary['requiredFactors'], $minimumTotalVerifiedFactors);
133        if ($summary['identifiedFactors'] < $requiredVerifiedFactors) {
134            // TRANSLATORS: Error shown when signing requires a minimum number of completed identification factors. %n is the required number of verified factors.
135            throw new LibresignException(
136                $this->l10n->n(
137                    'You need to complete at least %n identification factor before signing.',
138                    'You need to complete at least %n identification factors before signing.',
139                    $requiredVerifiedFactors
140                )
141            );
142        }
143    }
144
145    /**
146     * Pure logic: resolves maximum minimum requirement from settings array.
147     * @param list<mixed> $settings
148     * @param array<string, true> $methodSet
149     * @return int|null
150     */
151    public function resolveMinimumFromSettingsList(array $settings, array $methodSet): ?int {
152        return $this->resolveMinimumFromSettings($settings, $methodSet);
153    }
154
155    /**
156     * @param list<string> $methodNames
157     */
158    private function resolveMinimumTotalVerifiedFactors(SignRequest $signRequest, array $methodNames, bool $hasOptionalFactor): ?int {
159        // Runtime minimum enforcement is enabled only when optional factors exist.
160        if (!$hasOptionalFactor) {
161            return null;
162        }
163
164        $methodSet = array_fill_keys($methodNames, true);
165
166        $minimumFromSnapshot = $this->resolveMinimumTotalVerifiedFactorsFromPolicySnapshot($signRequest, $methodSet);
167        if ($minimumFromSnapshot !== null) {
168            return $minimumFromSnapshot;
169        }
170
171        return $this->resolveMinimumFromSettings(
172            $this->identifyMethodService->getIdentifyMethodsSettings(),
173            $methodSet,
174        );
175    }
176
177    /**
178     * @param list<mixed> $settings
179     * @param array<string, true> $methodSet
180     */
181    private function resolveMinimumFromSettings(array $settings, array $methodSet): ?int {
182        $minimum = null;
183        foreach ($settings as $setting) {
184            $candidate = $this->resolveMinimumCandidate($setting, $methodSet);
185            if ($candidate === null) {
186                continue;
187            }
188
189            $minimum = $minimum === null ? $candidate : max($minimum, $candidate);
190        }
191
192        return $minimum;
193    }
194
195    /**
196     * @param mixed $setting
197     * @param array<string, true> $methodSet
198     */
199    private function resolveMinimumCandidate(mixed $setting, array $methodSet): ?int {
200        if (!is_array($setting) || empty($setting['name']) || !isset($methodSet[$setting['name']])) {
201            return null;
202        }
203        if (!array_key_exists('minimumTotalVerifiedFactors', $setting) || !is_numeric($setting['minimumTotalVerifiedFactors'])) {
204            return null;
205        }
206
207        $candidate = (int)$setting['minimumTotalVerifiedFactors'];
208        return $candidate < 1 ? null : $candidate;
209    }
210
211    /**
212     * @param array<string, true> $methodSet
213     */
214    private function resolveMinimumTotalVerifiedFactorsFromPolicySnapshot(SignRequest $signRequest, array $methodSet): ?int {
215        try {
216            $file = $this->fileMapper->getById($signRequest->getFileId());
217        } catch (\Throwable) {
218            return null;
219        }
220
221        $metadata = $file->getMetadata() ?? [];
222        if (!isset($metadata['policy_snapshot']) || !is_array($metadata['policy_snapshot'])) {
223            return null;
224        }
225
226        $entry = $metadata['policy_snapshot'][IdentifyMethodsPolicy::KEY] ?? null;
227        if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) {
228            return null;
229        }
230
231        $normalized = IdentifyMethodsPolicyValue::normalize($entry['effectiveValue']);
232        $factors = IdentifyMethodsPolicyValue::extractFactors($normalized);
233        if (empty($factors)) {
234            return null;
235        }
236
237        return $this->resolveMinimumFromSettings($factors, $methodSet);
238    }
239}