Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
19 / 19
CRAP
100.00% covered (success)
100.00%
1 / 1
SignatureRejectionPolicyConfig
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
19 / 19
33
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaults
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 fromValues
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 fromKeyedValues
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 toKeyedValues
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBehavior
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cancelsWorkflow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getCommentMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowsComment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVisibility
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCommentVisibility
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRejectionVisibleTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isCommentVisibleTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 normalizeEnabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 normalizeBehavior
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 normalizeCommentMode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 normalizeVisibility
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeKeyedValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
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\SignatureRejection;
10
11use OCA\Libresign\Enum\SignatureRejectionBehavior;
12use OCA\Libresign\Enum\SignatureRejectionCommentMode;
13use OCA\Libresign\Enum\SignatureRejectionVisibility;
14
15/**
16 * The five rejection settings read as one configuration.
17 *
18 * Each setting is its own policy key, so an administrator can allow or enforce
19 * them one by one. They only describe a workflow when read together, which is
20 * what this object is for: it is built from the five resolved values and is the
21 * shape the signing flow, the snapshot and the cross-setting validator work
22 * with.
23 *
24 * Building one always collapses settings that cannot apply: nothing is
25 * configurable while rejection is disabled, the comment audience is meaningless
26 * while comments are disabled, and the comment can never reach further than the
27 * rejection itself. Collapsing here rather than at each reader keeps stored
28 * values, resolved values and snapshots consistent, and keeps a stale or
29 * hand-written layer from disclosing more than the rules allow.
30 *
31 * @psalm-type SignatureRejectionPolicyShape = array{
32 *     rejection_enabled: bool,
33 *     rejection_behavior: 'cancel'|'continue',
34 *     rejection_comment_mode: 'disabled'|'optional'|'required',
35 *     rejection_visibility: 'requester'|'participants'|'public',
36 *     rejection_comment_visibility: 'requester'|'participants'|'public',
37 * }
38 */
39final class SignatureRejectionPolicyConfig {
40    /**
41     * Signature rejection is opt-in: with the defaults below LibreSign keeps the
42     * behavior it had before the rejection workflow existed.
43     *
44     * Whether a comment is private is deliberately not part of this shape. A
45     * comment can carry personal or legal information, so the choice belongs to
46     * the signer who writes it, never to an administrator.
47     */
48    public const DEFAULT_ENABLED = false;
49    public const DEFAULT_BEHAVIOR = SignatureRejectionBehavior::CANCEL;
50    public const DEFAULT_COMMENT_MODE = SignatureRejectionCommentMode::DISABLED;
51    public const DEFAULT_VISIBILITY = SignatureRejectionVisibility::REQUESTER;
52    public const DEFAULT_COMMENT_VISIBILITY = SignatureRejectionVisibility::REQUESTER;
53
54    private function __construct(
55        private readonly bool $enabled,
56        private readonly SignatureRejectionBehavior $behavior,
57        private readonly SignatureRejectionCommentMode $commentMode,
58        private readonly SignatureRejectionVisibility $visibility,
59        private readonly SignatureRejectionVisibility $commentVisibility,
60    ) {
61    }
62
63    public static function defaults(): self {
64        return new self(
65            self::DEFAULT_ENABLED,
66            self::DEFAULT_BEHAVIOR,
67            self::DEFAULT_COMMENT_MODE,
68            self::DEFAULT_VISIBILITY,
69            self::DEFAULT_COMMENT_VISIBILITY,
70        );
71    }
72
73    /**
74     * Build the configuration from the five raw values, in whatever shape the
75     * policy layers, a request payload or a snapshot stored them.
76     */
77    public static function fromValues(
78        mixed $enabled = null,
79        mixed $behavior = null,
80        mixed $commentMode = null,
81        mixed $visibility = null,
82        mixed $commentVisibility = null,
83    ): self {
84        if (!self::normalizeEnabled($enabled)) {
85            return self::defaults();
86        }
87
88        $normalizedCommentMode = self::normalizeCommentMode($commentMode);
89        $normalizedVisibility = self::normalizeVisibility($visibility, self::DEFAULT_VISIBILITY);
90        $normalizedCommentVisibility = $normalizedCommentMode === SignatureRejectionCommentMode::DISABLED
91            ? self::DEFAULT_COMMENT_VISIBILITY
92            : self::normalizeVisibility($commentVisibility, self::DEFAULT_COMMENT_VISIBILITY)->narrowest($normalizedVisibility);
93
94        return new self(
95            true,
96            self::normalizeBehavior($behavior),
97            $normalizedCommentMode,
98            $normalizedVisibility,
99            $normalizedCommentVisibility,
100        );
101    }
102
103    /**
104     * Build the configuration from a map keyed by policy key, such as the values
105     * resolved for a user or the entries frozen in a policy snapshot. Missing
106     * keys fall back to the system default of that setting.
107     *
108     * @param array<string, mixed> $values
109     */
110    public static function fromKeyedValues(array $values): self {
111        return self::fromValues(
112            $values[SignatureRejectionPolicy::KEY_ENABLED] ?? null,
113            $values[SignatureRejectionPolicy::KEY_BEHAVIOR] ?? null,
114            $values[SignatureRejectionPolicy::KEY_COMMENT_MODE] ?? null,
115            $values[SignatureRejectionPolicy::KEY_VISIBILITY] ?? null,
116            $values[SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY] ?? null,
117        );
118    }
119
120    /** @return SignatureRejectionPolicyShape */
121    public function toKeyedValues(): array {
122        return [
123            SignatureRejectionPolicy::KEY_ENABLED => $this->enabled,
124            SignatureRejectionPolicy::KEY_BEHAVIOR => $this->behavior->value,
125            SignatureRejectionPolicy::KEY_COMMENT_MODE => $this->commentMode->value,
126            SignatureRejectionPolicy::KEY_VISIBILITY => $this->visibility->value,
127            SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY => $this->commentVisibility->value,
128        ];
129    }
130
131    public function isEnabled(): bool {
132        return $this->enabled;
133    }
134
135    public function getBehavior(): SignatureRejectionBehavior {
136        return $this->behavior;
137    }
138
139    public function cancelsWorkflow(): bool {
140        return $this->enabled && $this->behavior === SignatureRejectionBehavior::CANCEL;
141    }
142
143    public function getCommentMode(): SignatureRejectionCommentMode {
144        return $this->commentMode;
145    }
146
147    public function allowsComment(): bool {
148        return $this->commentMode !== SignatureRejectionCommentMode::DISABLED;
149    }
150
151    public function getVisibility(): SignatureRejectionVisibility {
152        return $this->visibility;
153    }
154
155    public function getCommentVisibility(): SignatureRejectionVisibility {
156        return $this->commentVisibility;
157    }
158
159    /** True when the audience below may learn that the signer rejected. */
160    public function isRejectionVisibleTo(SignatureRejectionVisibility $audience): bool {
161        return $this->enabled && $this->visibility->covers($audience);
162    }
163
164    /** True when the audience below may read the words the signer wrote. */
165    public function isCommentVisibleTo(SignatureRejectionVisibility $audience): bool {
166        return $this->enabled && $this->allowsComment() && $this->commentVisibility->covers($audience);
167    }
168
169    public static function normalizeEnabled(mixed $rawValue): bool {
170        if (is_bool($rawValue)) {
171            return $rawValue;
172        }
173
174        if (is_string($rawValue)) {
175            return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true);
176        }
177
178        if (is_int($rawValue) || is_float($rawValue)) {
179            return $rawValue > 0;
180        }
181
182        return self::DEFAULT_ENABLED;
183    }
184
185    public static function normalizeBehavior(mixed $rawValue): SignatureRejectionBehavior {
186        if ($rawValue instanceof SignatureRejectionBehavior) {
187            return $rawValue;
188        }
189
190        return is_string($rawValue)
191            ? SignatureRejectionBehavior::tryFrom(trim($rawValue)) ?? self::DEFAULT_BEHAVIOR
192            : self::DEFAULT_BEHAVIOR;
193    }
194
195    public static function normalizeCommentMode(mixed $rawValue): SignatureRejectionCommentMode {
196        if ($rawValue instanceof SignatureRejectionCommentMode) {
197            return $rawValue;
198        }
199
200        return is_string($rawValue)
201            ? SignatureRejectionCommentMode::tryFrom(trim($rawValue)) ?? self::DEFAULT_COMMENT_MODE
202            : self::DEFAULT_COMMENT_MODE;
203    }
204
205    public static function normalizeVisibility(mixed $rawValue, SignatureRejectionVisibility $fallback): SignatureRejectionVisibility {
206        return SignatureRejectionVisibility::tryFromValue($rawValue, $fallback);
207    }
208
209    /**
210     * Normalize a raw value the way the policy key that owns it does, so a value
211     * read back from a request, a layer or a snapshot can be compared with a
212     * resolved one.
213     */
214    public static function normalizeKeyedValue(string $policyKey, mixed $rawValue): bool|string {
215        return match ($policyKey) {
216            SignatureRejectionPolicy::KEY_BEHAVIOR => self::normalizeBehavior($rawValue)->value,
217            SignatureRejectionPolicy::KEY_COMMENT_MODE => self::normalizeCommentMode($rawValue)->value,
218            SignatureRejectionPolicy::KEY_VISIBILITY => self::normalizeVisibility($rawValue, self::DEFAULT_VISIBILITY)->value,
219            SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY => self::normalizeVisibility($rawValue, self::DEFAULT_COMMENT_VISIBILITY)->value,
220            default => self::normalizeEnabled($rawValue),
221        };
222    }
223}