Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.62% covered (success)
97.62%
41 / 42
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureRejectionPolicyService
97.62% covered (success)
97.62%
41 / 42
91.67% covered (success)
91.67%
11 / 12
25
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
 getConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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
 getCommentMode
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
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
 findSnapshot
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findSnapshotOnChildren
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 findSnapshotOnEnvelope
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractSnapshot
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
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\SignatureRejection;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Enum\SignatureRejectionBehavior;
14use OCA\Libresign\Enum\SignatureRejectionCommentMode;
15use OCA\Libresign\Enum\SignatureRejectionVisibility;
16use OCA\Libresign\Service\Policy\Provider\SignatureRejection\SignatureRejectionPolicy;
17use OCA\Libresign\Service\Policy\Provider\SignatureRejection\SignatureRejectionPolicyConfig;
18
19/**
20 * Reads the rejection rules that a signature request was created with.
21 *
22 * The configuration frozen on the request is the only source of truth for the
23 * signing flow: the live policy is never consulted here, so a later policy
24 * change cannot alter an existing request, and a request that never recorded a
25 * configuration keeps rejection disabled no matter what the policy allows
26 * today.
27 */
28class SignatureRejectionPolicyService {
29    public function __construct(
30        private FileMapper $fileMapper,
31    ) {
32    }
33
34    public function getConfig(?FileEntity $file = null): SignatureRejectionPolicyConfig {
35        $snapshot = $this->findSnapshot($file);
36
37        return $snapshot === null
38            ? SignatureRejectionPolicyConfig::defaults()
39            : SignatureRejectionPolicyConfig::fromKeyedValues($snapshot);
40    }
41
42    public function isEnabled(?FileEntity $file = null): bool {
43        return $this->getConfig($file)->isEnabled();
44    }
45
46    public function getBehavior(?FileEntity $file = null): SignatureRejectionBehavior {
47        return $this->getConfig($file)->getBehavior();
48    }
49
50    public function getCommentMode(?FileEntity $file = null): SignatureRejectionCommentMode {
51        return $this->getConfig($file)->getCommentMode();
52    }
53
54    public function cancelsWorkflow(?FileEntity $file = null): bool {
55        return $this->getConfig($file)->cancelsWorkflow();
56    }
57
58    public function getVisibility(?FileEntity $file = null): SignatureRejectionVisibility {
59        return $this->getConfig($file)->getVisibility();
60    }
61
62    public function getCommentVisibility(?FileEntity $file = null): SignatureRejectionVisibility {
63        return $this->getConfig($file)->getCommentVisibility();
64    }
65
66    /**
67     * An envelope is created before the file policy appliers run, so a freshly
68     * created envelope carries no configuration of its own and the one the
69     * request was created with lives on the documents it contains. A requester
70     * editing the envelope later writes the new configuration on the envelope
71     * itself.
72     *
73     * Every document of an envelope therefore answers with the configuration of
74     * the envelope, so that documents added to an envelope after it was created
75     * cannot end up governed by different rules than the ones already in it.
76     *
77     * @return array<string, mixed>|null
78     */
79    private function findSnapshot(?FileEntity $file): ?array {
80        if (!$file instanceof FileEntity) {
81            return null;
82        }
83
84        $ownSnapshot = $this->extractSnapshot($file->getMetadata() ?? []);
85
86        if ($file->isEnvelope()) {
87            return $ownSnapshot ?? $this->findSnapshotOnChildren($file);
88        }
89
90        if ($file->hasParent()) {
91            return $this->findSnapshotOnEnvelope($file) ?? $ownSnapshot;
92        }
93
94        return $ownSnapshot;
95    }
96
97    /**
98     * The oldest document of the envelope carries the configuration the request
99     * was created with, so it is the one that answers for the whole envelope.
100     *
101     * @return array<string, mixed>|null
102     */
103    private function findSnapshotOnChildren(FileEntity $envelope): ?array {
104        $envelopeId = $envelope->getId();
105        if ($envelopeId === null) {
106            return null;
107        }
108
109        $children = $this->fileMapper->getChildrenFiles($envelopeId);
110        usort($children, static fn (FileEntity $a, FileEntity $b): int => ($a->getId() ?? 0) <=> ($b->getId() ?? 0));
111
112        foreach ($children as $child) {
113            $childSnapshot = $this->extractSnapshot($child->getMetadata() ?? []);
114            if ($childSnapshot !== null) {
115                return $childSnapshot;
116            }
117        }
118
119        return null;
120    }
121
122    /** @return array<string, mixed>|null */
123    private function findSnapshotOnEnvelope(FileEntity $file): ?array {
124        try {
125            $envelope = $this->fileMapper->getById($file->getParentFileId());
126        } catch (\Throwable) {
127            return null;
128        }
129
130        return $this->findSnapshot($envelope);
131    }
132
133    /**
134     * A request records one snapshot entry per rejection setting. Entries that
135     * are missing keep the default of their setting, so a snapshot written by an
136     * older version of the request still reads cleanly.
137     *
138     * @param array<string, mixed> $fileMetadata
139     * @return array<string, mixed>|null
140     */
141    private function extractSnapshot(array $fileMetadata): ?array {
142        $policySnapshot = $fileMetadata['policy_snapshot'] ?? null;
143        if (!is_array($policySnapshot)) {
144            return null;
145        }
146
147        $values = [];
148        foreach (SignatureRejectionPolicy::ALL_KEYS as $policyKey) {
149            $entry = $policySnapshot[$policyKey] ?? null;
150            if (is_array($entry) && array_key_exists('effectiveValue', $entry)) {
151                $values[$policyKey] = $entry['effectiveValue'];
152            }
153        }
154
155        return $values === [] ? null : $values;
156    }
157}