diff --git a/lib/Controller/AccountController.php b/lib/Controller/AccountController.php index 7831ab523d..57d6d0ebd4 100644 --- a/lib/Controller/AccountController.php +++ b/lib/Controller/AccountController.php @@ -42,6 +42,7 @@ * @psalm-import-type LibresignAccountSettingsUpdateResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignActionMessageResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignConfigValueResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignUserConfigValue from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignCreateToSignResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignFile from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions @@ -409,7 +410,7 @@ public function readPfxData(string $password): DataResponse { * Set user config value * * @param string $key Config key - * @param mixed $value Config value + * @param LibresignUserConfigValue $value Config value as a JSON string, number, boolean, list or nested object. Null is not a storable setting. * @return DataResponse|DataResponse * * 200: Config updated @@ -419,19 +420,24 @@ public function readPfxData(string $password): DataResponse { #[NoCSRFRequired] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/account/config/{key}', requirements: ['apiVersion' => '(v1)'])] - public function setConfig(string $key): DataResponse { + public function setConfig(string $key, mixed $value): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { throw new \Exception('User not authenticated'); } - $data = $this->request->getParams(); - $value = $data['value'] ?? null; + if ($value === null) { + throw new \InvalidArgumentException('A non-null config value is required.'); + } if (is_bool($value)) { $value = $value ? '1' : '0'; } elseif (is_array($value)) { - $value = json_encode($value); + $value = json_encode($value, JSON_THROW_ON_ERROR); + } elseif (is_int($value) || is_float($value)) { + $value = (string)$value; + } elseif (!is_string($value)) { + throw new \InvalidArgumentException('Unsupported configuration value.'); } $this->userConfig->setValueString($user->getUID(), Application::APP_ID, $key, $value); diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php index 633ecbd5b2..fb1565bc5a 100644 --- a/lib/Controller/PolicyController.php +++ b/lib/Controller/PolicyController.php @@ -25,6 +25,7 @@ /** * @psalm-import-type LibresignErrorResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePolicyValue from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignPolicyValueMap from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePoliciesResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectiveCompoundPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions @@ -346,7 +347,7 @@ public function setGroup(string $groupId, string $policyKey, null|bool|int|float * them is stored, so the result does not depend on the order of the values. * * @param string $parentPolicyKey Policy identifier the other settings are grouped under. - * @param array> $values Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it. + * @param LibresignPolicyValueMap $values Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it. * @param array $allowChildOverride Whether lower layers may override each saved value, keyed by policy identifier. * @return DataResponse|DataResponse * @@ -392,7 +393,7 @@ public function setSystemCompound(string $parentPolicyKey, array $values = [], a * * @param string $groupId Group identifier that receives the policy bindings. * @param string $parentPolicyKey Policy identifier the other settings are grouped under. - * @param array> $values Values to persist for the group, keyed by policy identifier. + * @param LibresignPolicyValueMap $values Values to persist for the group, keyed by policy identifier. * @param array $allowChildOverride Whether users and requests below this group may override each saved value, keyed by policy identifier. * @return DataResponse|DataResponse|DataResponse * @@ -452,7 +453,7 @@ public function setGroupCompound(string $groupId, string $parentPolicyKey, array * Save several values of the same composite policy as user preferences * * @param string $parentPolicyKey Policy identifier the other settings are grouped under. - * @param array> $values Values to persist as the current user's defaults, keyed by policy identifier. + * @param LibresignPolicyValueMap $values Values to persist as the current user's defaults, keyed by policy identifier. * @return DataResponse|DataResponse * * 200: OK @@ -497,7 +498,7 @@ public function setUserPreferenceCompound(string $parentPolicyKey, array $values * * @param string $userId Target user identifier that receives the policy assignments. * @param string $parentPolicyKey Policy identifier the other settings are grouped under. - * @param array> $values Values to persist for the target user, keyed by policy identifier. + * @param LibresignPolicyValueMap $values Values to persist for the target user, keyed by policy identifier. * @param array $allowChildOverride Whether the target user may still override each assigned value, keyed by policy identifier. * @return DataResponse|DataResponse|DataResponse * diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index 54cc623c8f..6e538af3c0 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -27,6 +27,7 @@ /** * @psalm-import-type LibresignNewFile from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignRequestPolicy from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignNewSigner from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignDetailedFile from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignDetailedFileResponse from \OCA\Libresign\ResponseDefinitions @@ -66,7 +67,7 @@ public function __construct( * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. * @param string|null $callback URL that will receive a POST after the document is signed * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param array|null $policy Structured policy payload with request-level overrides and active context. + * @param LibresignRequestPolicy|null $policy Structured policy payload with request-level overrides and active context. * @return DataResponse|DataResponse * * 200: OK @@ -136,7 +137,7 @@ public function requestSignature( * @param list|null $visibleElements Visible elements on document * @param LibresignNewFile|null $file File object. Supports nodeId (a non-negative integer or its canonical decimal string), url, base64 or path when creating a new request. * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param array|null $policy Structured policy payload with request-level overrides and active context. + * @param LibresignRequestPolicy|null $policy Structured policy payload with request-level overrides and active context. * @param string|null $name The name of file to sign * @param LibresignFolderSettings $settings Settings to define how and where the file should be stored * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. diff --git a/lib/Controller/SignFileController.php b/lib/Controller/SignFileController.php index 41ea5bef4d..62d9152c28 100644 --- a/lib/Controller/SignFileController.php +++ b/lib/Controller/SignFileController.php @@ -54,6 +54,7 @@ * @psalm-import-type LibresignSignActionResponse from ResponseDefinitions * @psalm-import-type LibresignSignatureRejectionResponse from ResponseDefinitions * @psalm-import-type LibresignSignerDeviceGeolocation from ResponseDefinitions + * @psalm-import-type LibresignSigningElement from ResponseDefinitions */ class SignFileController extends AEnvironmentAwareOCSController implements ISignatureUuid { @@ -89,12 +90,11 @@ public function __construct( * * @param int $fileId Id of LibreSign file * @param string $method Signature method - * @param array $elements List of visible elements + * @param list $elements List of visible elements * @param string $identifyValue Identify value * @param string $token Token, commonly send by email * @param bool $async Execute signing asynchronously when possible - * @param LibresignSignerDeviceGeolocation $deviceGeolocation Device-reported geolocation metadata submitted by the signing client - * @psalm-param array $deviceGeolocation + * @param LibresignSignerDeviceGeolocation|null $deviceGeolocation Optional device geolocation, omitted when absent; legacy [] is accepted * @return DataResponse|DataResponse * * 200: OK @@ -106,22 +106,24 @@ public function __construct( #[PublicPage] #[OpenAPI(tags: ['signing'])] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/file_id/{fileId}', requirements: ['apiVersion' => '(v1)'])] - public function signByFileId(int $fileId, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, array $deviceGeolocation = []): DataResponse { - return $this->sign($method, $elements, $identifyValue, $token, $fileId, null, $async, $deviceGeolocation); + public function signByFileId(int $fileId, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, ?array $deviceGeolocation = null): DataResponse { + return $this->sign($method, $elements, $identifyValue, $token, $fileId, null, $async, $deviceGeolocation ?? []); } /** * Sign a file using file UUID * + * Identification-document approvals use the query string ?idDocApproval=true. + * The signer middleware resolves that query before the controller runs. + * * @param string $uuid UUID of LibreSign file * @param string $method Signature method - * @param array $elements List of visible elements + * @param list $elements List of visible elements * @param string $identifyValue Identify value * @param string $token Token, commonly send by email * @param bool $async Execute signing asynchronously when possible - * @param LibresignSignerDeviceGeolocation $deviceGeolocation Device-reported geolocation metadata submitted by the signing client + * @param LibresignSignerDeviceGeolocation|null $deviceGeolocation Optional device geolocation, omitted when absent; legacy [] is accepted * @param bool $saveSignature Explicitly save the external email signer's visible signature for future documents - * @psalm-param array $deviceGeolocation * @return DataResponse|DataResponse * * 200: OK @@ -133,8 +135,8 @@ public function signByFileId(int $fileId, string $method, array $elements = [], #[PublicPage] #[OpenAPI(tags: ['signing'])] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}', requirements: ['apiVersion' => '(v1)'])] - public function signBySignerUuid(string $uuid, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, array $deviceGeolocation = [], bool $saveSignature = false): DataResponse { - return $this->sign($method, $elements, $identifyValue, $token, null, $uuid, $async, $deviceGeolocation, $saveSignature); + public function signBySignerUuid(string $uuid, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, ?array $deviceGeolocation = null, bool $saveSignature = false): DataResponse { + return $this->sign($method, $elements, $identifyValue, $token, null, $uuid, $async, $deviceGeolocation ?? [], $saveSignature); } /** @@ -153,7 +155,7 @@ public function sign( ): DataResponse { try { $user = $this->userSession->getUser(); - $isIdDocApproval = $this->request->getParam('idDocApproval') === 'true'; + $isIdDocApproval = $this->isIdentificationDocumentApproval(); if ($isIdDocApproval && $signRequestUuid) { $libreSignFile = $this->signFileService->getFileByUuid($signRequestUuid); @@ -447,10 +449,14 @@ public function signRenew(string $method): DataResponse { /** * Get code to sign the document using UUID * + * For identification-document approvals the frontend sends ?idDocApproval=true + * in the URL query. Nextcloud also accepts the same flag in the JSON body. + * The signer middleware reads the combined request parameters. + * * @param string $uuid UUID of LibreSign file - * @param 'account'|'email'|null $identifyMethod Identify signer method + * @param string|null $identifyMethod Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation * @param string|null $signMethod Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken - * @param 'true'|null $idDocApproval Literal string "true" when the UUID is an identification-document file UUID (matches middleware RequireSigner) + * @param string|null $idDocApproval Only the literal "true" selects the identification-document approval context. Query or JSON body is accepted. * @return DataResponse|DataResponse * * 200: OK @@ -464,12 +470,12 @@ public function signRenew(string $method): DataResponse { #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}/code', requirements: ['apiVersion' => '(v1)'])] public function requestCodeBySignerUuid( string $uuid, - ?string $identifyMethod, - ?string $signMethod, + ?string $identifyMethod = null, + ?string $signMethod = null, ?string $idDocApproval = null, ): DataResponse { try { - if ($idDocApproval === 'true') { + if ($this->isIdentificationDocumentApproval($idDocApproval)) { // In this context the uuid is the one of the file, not of a sign request. $libreSignFile = $this->signFileService->getFileByUuid($uuid); $signRequest = $this->signFileService->getSignRequestToSign($libreSignFile, null, $this->userSession->getUser()); @@ -491,7 +497,7 @@ public function requestCodeBySignerUuid( * Get code to sign the document using FileID * * @param int $fileId Id of LibreSign file - * @param 'account'|'email'|null $identifyMethod Identify signer method + * @param string|null $identifyMethod Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation * @param string|null $signMethod Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken * @return DataResponse|DataResponse * @@ -505,7 +511,7 @@ public function requestCodeBySignerUuid( #[PublicPage] #[OpenAPI(tags: ['signing'])] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/file_id/{fileId}/code', requirements: ['apiVersion' => '(v1)'])] - public function requestCodeByFileId(int $fileId, ?string $identifyMethod, ?string $signMethod): DataResponse { + public function requestCodeByFileId(int $fileId, ?string $identifyMethod = null, ?string $signMethod = null): DataResponse { try { $signRequest = $this->signRequestMapper->getByFileIdAndUserId($fileId); } catch (\Throwable) { @@ -519,6 +525,10 @@ public function requestCodeByFileId(int $fileId, ?string $identifyMethod, ?strin ); } + private function isIdentificationDocumentApproval(?string $idDocApproval = null): bool { + return $idDocApproval === 'true' || $this->request->getParam('idDocApproval') === 'true'; + } + /** * @todo validate if can request code * @return DataResponse diff --git a/lib/Controller/SignatureElementsController.php b/lib/Controller/SignatureElementsController.php index 4fdb810246..eb04a38748 100644 --- a/lib/Controller/SignatureElementsController.php +++ b/lib/Controller/SignatureElementsController.php @@ -45,6 +45,8 @@ /** * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignSavedSignatureElement from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignSavedSignatureFile from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignUserElement from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignUserElementsMessageResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignUserElementsResponse from \OCA\Libresign\ResponseDefinitions @@ -73,7 +75,7 @@ public function __construct( /** * Create signature element * - * @param array $elements Element object + * @param list $elements Saved signature elements * @return DataResponse|DataResponse * * 200: OK @@ -314,7 +316,7 @@ public function getSignatureElement(int $nodeId): DataResponse { * * @param int $nodeId Node id of a Nextcloud file * @param string $type The type of signature element - * @param array $file Element object + * @param LibresignSavedSignatureFile $file Signature image supplied as base64 or URL * @return DataResponse|DataResponse * * 200: OK diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 049ac0f68d..462e7847b1 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -50,6 +50,22 @@ * @psalm-type LibresignSignerIpGeolocationStatus = 'resolved'|'not_found'|'unavailable' * @psalm-type LibresignSignerIpGeolocationUnavailableReason = 'database_not_ready'|'address_unavailable'|'lookup_failed' * @psalm-type LibresignGeoIpDatabaseStatus = 'not_configured'|'not_found'|'not_readable'|'invalid_database'|'unsupported_database'|'ready' + * @psalm-type LibresignSigningElement = array{ + * documentElementId: int, + * profileNodeId?: int, + * } + * @psalm-type LibresignSavedSignatureFile = array{ + * base64?: string, + * url?: string, + * } + * @psalm-type LibresignSavedSignatureElement = array{ + * type?: 'signature'|'initial'|'date'|'datetime'|'text', + * elementId?: int, + * nodeId?: int, + * starred?: bool, + * file?: LibresignSavedSignatureFile, + * coordinates?: LibresignCoordinate, + * } * @psalm-type LibresignSignerDeviceGeolocation = array{ * status: LibresignGeolocationCollectionStatus, * latitude?: float, @@ -433,7 +449,18 @@ * Validation and progress contracts * * @psalm-type LibresignPolicyScope = 'system'|'group'|'user' + * @psalm-type LibresignUserConfigValue = string|bool|numeric|list|array * @psalm-type LibresignEffectivePolicyValue = null|bool|numeric|string|array + * @psalm-type LibresignPolicyValueMap = array + * @psalm-type LibresignPolicyActiveContext = array{ + * type: 'group', + * id: non-empty-string, + * } + * @psalm-type LibresignRequestPolicyOverrides = array> + * @psalm-type LibresignRequestPolicy = array{ + * overrides?: LibresignRequestPolicyOverrides, + * activeContext?: LibresignPolicyActiveContext, + * } * @psalm-type LibresignEffectivePolicyMeta = array{ * defaultSystemValue?: LibresignEffectivePolicyValue, * appConfigKey?: string, diff --git a/openapi-administration.json b/openapi-administration.json index 09d6ac70dd..422fea5110 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -801,6 +801,12 @@ } } }, + "PolicyValueMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, "PublicCapabilities": { "type": "object", "properties": { @@ -4164,34 +4170,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it." }, "allowChildOverride": { "type": "object", diff --git a/openapi-full.json b/openapi-full.json index e707ffd1ee..c0765311b6 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2268,6 +2268,25 @@ "observer" ] }, + "PolicyActiveContext": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "group" + ] + }, + "id": { + "type": "string", + "minLength": 1 + } + } + }, "PolicyScope": { "type": "string", "enum": [ @@ -2572,6 +2591,12 @@ } } }, + "PolicyValueMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, "ProgressError": { "type": "object", "required": [ @@ -2752,6 +2777,33 @@ } } }, + "RequestPolicy": { + "type": "object", + "properties": { + "overrides": { + "$ref": "#/components/schemas/RequestPolicyOverrides" + }, + "activeContext": { + "$ref": "#/components/schemas/PolicyActiveContext" + } + } + }, + "RequestPolicyOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + ] + } + }, "RequestedBy": { "type": "object", "required": [ @@ -2812,6 +2864,49 @@ } } }, + "SavedSignatureElement": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "signature", + "initial", + "date", + "datetime", + "text" + ] + }, + "elementId": { + "type": "integer", + "format": "int64" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "starred": { + "type": "boolean" + }, + "file": { + "$ref": "#/components/schemas/SavedSignatureFile" + }, + "coordinates": { + "$ref": "#/components/schemas/Coordinate" + } + } + }, + "SavedSignatureFile": { + "type": "object", + "properties": { + "base64": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "Settings": { "type": "object", "required": [ @@ -3378,6 +3473,22 @@ } } }, + "SigningElement": { + "type": "object", + "required": [ + "documentElementId" + ], + "properties": { + "documentElementId": { + "type": "integer", + "format": "int64" + }, + "profileNodeId": { + "type": "integer", + "format": "int64" + } + } + }, "SigningJob": { "type": "object", "required": [ @@ -3510,6 +3621,31 @@ } ] }, + "UserConfigValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "object" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -5310,6 +5446,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/UserConfigValue", + "description": "Config value as a JSON string, number, boolean, list or nested object. Null is not a storable setting." + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -11226,34 +11381,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist for the group, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist for the group, keyed by policy identifier." }, "allowChildOverride": { "type": "object", @@ -11457,34 +11587,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist as the current user's defaults, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist as the current user's defaults, keyed by policy identifier." } } } @@ -11640,34 +11745,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist for the target user, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist for the target user, keyed by policy identifier." }, "allowChildOverride": { "type": "object", @@ -12215,13 +12295,14 @@ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, "policy": { - "type": "object", "nullable": true, "default": null, "description": "Structured policy payload with request-level overrides and active context.", - "additionalProperties": { - "type": "object" - } + "allOf": [ + { + "$ref": "#/components/schemas/RequestPolicy" + } + ] } } } @@ -12414,13 +12495,14 @@ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, "policy": { - "type": "object", "nullable": true, "default": null, "description": "Structured policy payload with request-level overrides and active context.", - "additionalProperties": { - "type": "object" - } + "allOf": [ + { + "$ref": "#/components/schemas/RequestPolicy" + } + ] }, "name": { "type": "string", @@ -12946,11 +13028,11 @@ "description": "Signature method" }, "elements": { - "type": "object", - "default": {}, + "type": "array", + "default": [], "description": "List of visible elements", - "additionalProperties": { - "type": "object" + "items": { + "$ref": "#/components/schemas/SigningElement" } }, "identifyValue": { @@ -12969,12 +13051,14 @@ "description": "Execute signing asynchronously when possible" }, "deviceGeolocation": { - "type": "object", - "default": {}, - "description": "Device-reported geolocation metadata submitted by the signing client", - "additionalProperties": { - "type": "object" - } + "nullable": true, + "default": null, + "description": "Optional device geolocation, omitted when absent; legacy [] is accepted", + "allOf": [ + { + "$ref": "#/components/schemas/SignerDeviceGeolocation" + } + ] } } } @@ -13083,6 +13167,7 @@ "post": { "operationId": "sign_file-sign-by-signer-uuid", "summary": "Sign a file using file UUID", + "description": "Identification-document approvals use the query string ?idDocApproval=true. The signer middleware resolves that query before the controller runs.", "tags": [ "signing" ], @@ -13110,11 +13195,11 @@ "description": "Signature method" }, "elements": { - "type": "object", - "default": {}, + "type": "array", + "default": [], "description": "List of visible elements", - "additionalProperties": { - "type": "object" + "items": { + "$ref": "#/components/schemas/SigningElement" } }, "identifyValue": { @@ -13133,12 +13218,14 @@ "description": "Execute signing asynchronously when possible" }, "deviceGeolocation": { - "type": "object", - "default": {}, - "description": "Device-reported geolocation metadata submitted by the signing client", - "additionalProperties": { - "type": "object" - } + "nullable": true, + "default": null, + "description": "Optional device geolocation, omitted when absent; legacy [] is accepted", + "allOf": [ + { + "$ref": "#/components/schemas/SignerDeviceGeolocation" + } + ] }, "saveSignature": { "type": "boolean", @@ -13640,6 +13727,7 @@ "post": { "operationId": "sign_file-request-code-by-signer-uuid", "summary": "Get code to sign the document using UUID", + "description": "For identification-document approvals the frontend sends ?idDocApproval=true in the URL query. Nextcloud also accepts the same flag in the JSON body. The signer middleware reads the combined request parameters.", "tags": [ "signing" ], @@ -13662,25 +13750,20 @@ "identifyMethod": { "type": "string", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": null, + "description": "Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation" }, "signMethod": { "type": "string", "nullable": true, + "default": null, "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" }, "idDocApproval": { "type": "string", "nullable": true, "default": null, - "enum": [ - "true" - ], - "description": "Literal string \"true\" when the UUID is an identification-document file UUID (matches middleware RequireSigner)" + "description": "Only the literal \"true\" selects the identification-document approval context. Query or JSON body is accepted." } } } @@ -13810,15 +13893,13 @@ "identifyMethod": { "type": "string", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": null, + "description": "Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation" }, "signMethod": { "type": "string", "nullable": true, + "default": null, "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" } } @@ -13959,10 +14040,10 @@ ], "properties": { "elements": { - "type": "object", - "description": "Element object", - "additionalProperties": { - "type": "object" + "type": "array", + "description": "Saved signature elements", + "items": { + "$ref": "#/components/schemas/SavedSignatureElement" } } } @@ -14577,12 +14658,9 @@ "description": "The type of signature element" }, "file": { - "type": "object", - "default": {}, - "description": "Element object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/SavedSignatureFile", + "default": [], + "description": "Signature image supplied as base64 or URL" } } } @@ -18041,34 +18119,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it." }, "allowChildOverride": { "type": "object", diff --git a/openapi.json b/openapi.json index bf1e6ae853..080247856b 100644 --- a/openapi.json +++ b/openapi.json @@ -1853,6 +1853,25 @@ "observer" ] }, + "PolicyActiveContext": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "group" + ] + }, + "id": { + "type": "string", + "minLength": 1 + } + } + }, "PolicyScope": { "type": "string", "enum": [ @@ -2142,6 +2161,12 @@ } } }, + "PolicyValueMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, "ProgressError": { "type": "object", "required": [ @@ -2322,6 +2347,33 @@ } } }, + "RequestPolicy": { + "type": "object", + "properties": { + "overrides": { + "$ref": "#/components/schemas/RequestPolicyOverrides" + }, + "activeContext": { + "$ref": "#/components/schemas/PolicyActiveContext" + } + } + }, + "RequestPolicyOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + ] + } + }, "RequestedBy": { "type": "object", "required": [ @@ -2338,6 +2390,49 @@ } } }, + "SavedSignatureElement": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "signature", + "initial", + "date", + "datetime", + "text" + ] + }, + "elementId": { + "type": "integer", + "format": "int64" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "starred": { + "type": "boolean" + }, + "file": { + "$ref": "#/components/schemas/SavedSignatureFile" + }, + "coordinates": { + "$ref": "#/components/schemas/Coordinate" + } + } + }, + "SavedSignatureFile": { + "type": "object", + "properties": { + "base64": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "Settings": { "type": "object", "required": [ @@ -2904,6 +2999,22 @@ } } }, + "SigningElement": { + "type": "object", + "required": [ + "documentElementId" + ], + "properties": { + "documentElementId": { + "type": "integer", + "format": "int64" + }, + "profileNodeId": { + "type": "integer", + "format": "int64" + } + } + }, "SigningJob": { "type": "object", "required": [ @@ -2968,6 +3079,31 @@ } ] }, + "UserConfigValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "object" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -4768,6 +4904,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/UserConfigValue", + "description": "Config value as a JSON string, number, boolean, list or nested object. Null is not a storable setting." + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10489,34 +10644,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist for the group, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist for the group, keyed by policy identifier." }, "allowChildOverride": { "type": "object", @@ -10720,34 +10850,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist as the current user's defaults, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist as the current user's defaults, keyed by policy identifier." } } } @@ -10903,34 +11008,9 @@ "type": "object", "properties": { "values": { - "type": "object", - "default": {}, - "description": "Values to persist for the target user, keyed by policy identifier.", - "additionalProperties": { - "nullable": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "integer", - "format": "int64" - }, - { - "type": "number", - "format": "double" - }, - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } + "$ref": "#/components/schemas/PolicyValueMap", + "default": [], + "description": "Values to persist for the target user, keyed by policy identifier." }, "allowChildOverride": { "type": "object", @@ -11478,13 +11558,14 @@ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, "policy": { - "type": "object", "nullable": true, "default": null, "description": "Structured policy payload with request-level overrides and active context.", - "additionalProperties": { - "type": "object" - } + "allOf": [ + { + "$ref": "#/components/schemas/RequestPolicy" + } + ] } } } @@ -11677,13 +11758,14 @@ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, "policy": { - "type": "object", "nullable": true, "default": null, "description": "Structured policy payload with request-level overrides and active context.", - "additionalProperties": { - "type": "object" - } + "allOf": [ + { + "$ref": "#/components/schemas/RequestPolicy" + } + ] }, "name": { "type": "string", @@ -12209,11 +12291,11 @@ "description": "Signature method" }, "elements": { - "type": "object", - "default": {}, + "type": "array", + "default": [], "description": "List of visible elements", - "additionalProperties": { - "type": "object" + "items": { + "$ref": "#/components/schemas/SigningElement" } }, "identifyValue": { @@ -12232,12 +12314,14 @@ "description": "Execute signing asynchronously when possible" }, "deviceGeolocation": { - "type": "object", - "default": {}, - "description": "Device-reported geolocation metadata submitted by the signing client", - "additionalProperties": { - "type": "object" - } + "nullable": true, + "default": null, + "description": "Optional device geolocation, omitted when absent; legacy [] is accepted", + "allOf": [ + { + "$ref": "#/components/schemas/SignerDeviceGeolocation" + } + ] } } } @@ -12346,6 +12430,7 @@ "post": { "operationId": "sign_file-sign-by-signer-uuid", "summary": "Sign a file using file UUID", + "description": "Identification-document approvals use the query string ?idDocApproval=true. The signer middleware resolves that query before the controller runs.", "tags": [ "signing" ], @@ -12373,11 +12458,11 @@ "description": "Signature method" }, "elements": { - "type": "object", - "default": {}, + "type": "array", + "default": [], "description": "List of visible elements", - "additionalProperties": { - "type": "object" + "items": { + "$ref": "#/components/schemas/SigningElement" } }, "identifyValue": { @@ -12396,12 +12481,14 @@ "description": "Execute signing asynchronously when possible" }, "deviceGeolocation": { - "type": "object", - "default": {}, - "description": "Device-reported geolocation metadata submitted by the signing client", - "additionalProperties": { - "type": "object" - } + "nullable": true, + "default": null, + "description": "Optional device geolocation, omitted when absent; legacy [] is accepted", + "allOf": [ + { + "$ref": "#/components/schemas/SignerDeviceGeolocation" + } + ] }, "saveSignature": { "type": "boolean", @@ -12903,6 +12990,7 @@ "post": { "operationId": "sign_file-request-code-by-signer-uuid", "summary": "Get code to sign the document using UUID", + "description": "For identification-document approvals the frontend sends ?idDocApproval=true in the URL query. Nextcloud also accepts the same flag in the JSON body. The signer middleware reads the combined request parameters.", "tags": [ "signing" ], @@ -12925,25 +13013,20 @@ "identifyMethod": { "type": "string", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": null, + "description": "Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation" }, "signMethod": { "type": "string", "nullable": true, + "default": null, "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" }, "idDocApproval": { "type": "string", "nullable": true, "default": null, - "enum": [ - "true" - ], - "description": "Literal string \"true\" when the UUID is an identification-document file UUID (matches middleware RequireSigner)" + "description": "Only the literal \"true\" selects the identification-document approval context. Query or JSON body is accepted." } } } @@ -13073,15 +13156,13 @@ "identifyMethod": { "type": "string", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": null, + "description": "Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation" }, "signMethod": { "type": "string", "nullable": true, + "default": null, "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" } } @@ -13222,10 +13303,10 @@ ], "properties": { "elements": { - "type": "object", - "description": "Element object", - "additionalProperties": { - "type": "object" + "type": "array", + "description": "Saved signature elements", + "items": { + "$ref": "#/components/schemas/SavedSignatureElement" } } } @@ -13840,12 +13921,9 @@ "description": "The type of signature element" }, "file": { - "type": "object", - "default": {}, - "description": "Element object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/SavedSignatureFile", + "default": [], + "description": "Signature image supplied as base64 or URL" } } } diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index b9b19e2f92..6a280db00c 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -563,6 +563,9 @@ export type components = { OID: string; CPS: string; }; + PolicyValueMap: { + [key: string]: components["schemas"]["EffectivePolicyValue"]; + }; PublicCapabilities: { libresign?: components["schemas"]["Capabilities"]; }; @@ -2014,13 +2017,9 @@ export interface operations { "application/json": { /** * @description Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether lower layers may override each saved value, keyed by policy identifier. * @default {} diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 9217014bfe..c2cde313ad 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -836,7 +836,10 @@ export type paths = { }; get?: never; put?: never; - /** Sign a file using file UUID */ + /** + * Sign a file using file UUID + * @description Identification-document approvals use the query string ?idDocApproval=true. The signer middleware resolves that query before the controller runs. + */ post: operations["sign_file-sign-by-signer-uuid"]; delete?: never; options?: never; @@ -904,7 +907,10 @@ export type paths = { }; get?: never; put?: never; - /** Get code to sign the document using UUID */ + /** + * Get code to sign the document using UUID + * @description For identification-document approvals the frontend sends ?idDocApproval=true in the URL query. Nextcloud also accepts the same flag in the JSON body. The signer middleware reads the combined request parameters. + */ post: operations["sign_file-request-code-by-signer-uuid"]; delete?: never; options?: never; @@ -1958,6 +1964,11 @@ export type components = { }; /** @enum {string} */ ParticipantRole: "signer" | "observer"; + PolicyActiveContext: { + /** @enum {string} */ + type: "group"; + id: string; + }; /** @enum {string} */ PolicyScope: "system" | "group" | "user"; PolicySection: { @@ -2041,6 +2052,9 @@ export type components = { PolicySnapshotSignerIpGeolocationValue: { mode: components["schemas"]["SignerIpGeolocationPolicyMode"]; }; + PolicyValueMap: { + [key: string]: components["schemas"]["EffectivePolicyValue"]; + }; ProgressError: { message: string; /** Format: int64 */ @@ -2098,6 +2112,13 @@ export type components = { PublicCapabilities: { libresign?: components["schemas"]["Capabilities"]; }; + RequestPolicy: { + overrides?: components["schemas"]["RequestPolicyOverrides"]; + activeContext?: components["schemas"]["PolicyActiveContext"]; + }; + RequestPolicyOverrides: { + [key: string]: components["schemas"]["EffectivePolicyValue"] | components["schemas"]["EffectivePolicyValue"][]; + }; RequestedBy: { userId: string; displayName: string | null; @@ -2110,6 +2131,21 @@ export type components = { id: string; value: (string | string[]) | null; }; + SavedSignatureElement: { + /** @enum {string} */ + type?: "signature" | "initial" | "date" | "datetime" | "text"; + /** Format: int64 */ + elementId?: number; + /** Format: int64 */ + nodeId?: number; + starred?: boolean; + file?: components["schemas"]["SavedSignatureFile"]; + coordinates?: components["schemas"]["Coordinate"]; + }; + SavedSignatureFile: { + base64?: string; + url?: string; + }; Settings: { canSign: boolean; canRequestSign: boolean; @@ -2277,6 +2313,12 @@ export type components = { statusText: string; participantRole?: components["schemas"]["ParticipantRole"]; }; + SigningElement: { + /** Format: int64 */ + documentElementId: number; + /** Format: int64 */ + profileNodeId?: number; + }; SigningJob: { /** @enum {string} */ status: "SIGNING_IN_PROGRESS"; @@ -2308,6 +2350,9 @@ export type components = { [key: string]: components["schemas"]["UserPolicyState"]; }; }; + UserConfigValue: string | boolean | number | Record[] | { + [key: string]: Record; + }; UserElement: { /** Format: int64 */ id: number; @@ -3040,7 +3085,14 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + /** @description Config value as a JSON string, number, boolean, list or nested object. Null is not a storable setting. */ + value: components["schemas"]["UserConfigValue"]; + }; + }; + }; responses: { /** @description Config updated */ 200: { @@ -5533,13 +5585,9 @@ export interface operations { "application/json": { /** * @description Values to persist for the group, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether users and requests below this group may override each saved value, keyed by policy identifier. * @default {} @@ -5628,13 +5676,9 @@ export interface operations { "application/json": { /** * @description Values to persist as the current user's defaults, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; }; }; }; @@ -5704,13 +5748,9 @@ export interface operations { "application/json": { /** * @description Values to persist for the target user, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether the target user may still override each assigned value, keyed by policy identifier. * @default {} @@ -5962,9 +6002,7 @@ export interface operations { * @description Structured policy payload with request-level overrides and active context. * @default null */ - policy?: { - [key: string]: Record; - } | null; + policy?: components["schemas"]["RequestPolicy"] | null; }; }; }; @@ -6058,9 +6096,7 @@ export interface operations { * @description Structured policy payload with request-level overrides and active context. * @default null */ - policy?: { - [key: string]: Record; - } | null; + policy?: components["schemas"]["RequestPolicy"] | null; /** * @description The name of file to sign * @default null @@ -6212,11 +6248,9 @@ export interface operations { method: string; /** * @description List of visible elements - * @default {} + * @default [] */ - elements?: { - [key: string]: Record; - }; + elements?: components["schemas"]["SigningElement"][]; /** * @description Identify value * @default @@ -6233,12 +6267,10 @@ export interface operations { */ async?: boolean; /** - * @description Device-reported geolocation metadata submitted by the signing client - * @default {} + * @description Optional device geolocation, omitted when absent; legacy [] is accepted + * @default null */ - deviceGeolocation?: { - [key: string]: Record; - }; + deviceGeolocation?: components["schemas"]["SignerDeviceGeolocation"] | null; }; }; }; @@ -6359,11 +6391,9 @@ export interface operations { method: string; /** * @description List of visible elements - * @default {} + * @default [] */ - elements?: { - [key: string]: Record; - }; + elements?: components["schemas"]["SigningElement"][]; /** * @description Identify value * @default @@ -6380,12 +6410,10 @@ export interface operations { */ async?: boolean; /** - * @description Device-reported geolocation metadata submitted by the signing client - * @default {} + * @description Optional device geolocation, omitted when absent; legacy [] is accepted + * @default null */ - deviceGeolocation?: { - [key: string]: Record; - }; + deviceGeolocation?: components["schemas"]["SignerDeviceGeolocation"] | null; /** * @description Explicitly save the external email signer's visible signature for future documents * @default false @@ -6612,18 +6640,20 @@ export interface operations { content: { "application/json": { /** - * @description Identify signer method - * @enum {string|null} + * @description Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation + * @default null + */ + identifyMethod?: string | null; + /** + * @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken + * @default null */ - identifyMethod?: "account" | "email" | null; - /** @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken */ signMethod?: string | null; /** - * @description Literal string "true" when the UUID is an identification-document file UUID (matches middleware RequireSigner) + * @description Only the literal "true" selects the identification-document approval context. Query or JSON body is accepted. * @default null - * @enum {string|null} */ - idDocApproval?: "true" | null; + idDocApproval?: string | null; }; }; }; @@ -6678,11 +6708,14 @@ export interface operations { content: { "application/json": { /** - * @description Identify signer method - * @enum {string|null} + * @description Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation + * @default null + */ + identifyMethod?: string | null; + /** + * @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken + * @default null */ - identifyMethod?: "account" | "email" | null; - /** @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken */ signMethod?: string | null; }; }; @@ -6781,10 +6814,8 @@ export interface operations { requestBody: { content: { "application/json": { - /** @description Element object */ - elements: { - [key: string]: Record; - }; + /** @description Saved signature elements */ + elements: components["schemas"]["SavedSignatureElement"][]; }; }; }; @@ -7051,12 +7082,10 @@ export interface operations { */ type?: string; /** - * @description Element object - * @default {} + * @description Signature image supplied as base64 or URL + * @default [] */ - file?: { - [key: string]: Record; - }; + file?: components["schemas"]["SavedSignatureFile"]; }; }; }; @@ -8522,13 +8551,9 @@ export interface operations { "application/json": { /** * @description Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether lower layers may override each saved value, keyed by policy identifier. * @default {} diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 8595d7c47d..095259b876 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -831,7 +831,10 @@ export type paths = { }; get?: never; put?: never; - /** Sign a file using file UUID */ + /** + * Sign a file using file UUID + * @description Identification-document approvals use the query string ?idDocApproval=true. The signer middleware resolves that query before the controller runs. + */ post: operations["sign_file-sign-by-signer-uuid"]; delete?: never; options?: never; @@ -899,7 +902,10 @@ export type paths = { }; get?: never; put?: never; - /** Get code to sign the document using UUID */ + /** + * Get code to sign the document using UUID + * @description For identification-document approvals the frontend sends ?idDocApproval=true in the URL query. Nextcloud also accepts the same flag in the JSON body. The signer middleware reads the combined request parameters. + */ post: operations["sign_file-request-code-by-signer-uuid"]; delete?: never; options?: never; @@ -1518,6 +1524,11 @@ export type components = { }; /** @enum {string} */ ParticipantRole: "signer" | "observer"; + PolicyActiveContext: { + /** @enum {string} */ + type: "group"; + id: string; + }; /** @enum {string} */ PolicyScope: "system" | "group" | "user"; PolicySnapshotBooleanEntry: { @@ -1597,6 +1608,9 @@ export type components = { PolicySnapshotSignerIpGeolocationValue: { mode: components["schemas"]["SignerIpGeolocationPolicyMode"]; }; + PolicyValueMap: { + [key: string]: components["schemas"]["EffectivePolicyValue"]; + }; ProgressError: { message: string; /** Format: int64 */ @@ -1654,10 +1668,32 @@ export type components = { PublicCapabilities: { libresign?: components["schemas"]["Capabilities"]; }; + RequestPolicy: { + overrides?: components["schemas"]["RequestPolicyOverrides"]; + activeContext?: components["schemas"]["PolicyActiveContext"]; + }; + RequestPolicyOverrides: { + [key: string]: components["schemas"]["EffectivePolicyValue"] | components["schemas"]["EffectivePolicyValue"][]; + }; RequestedBy: { userId: string; displayName: string | null; }; + SavedSignatureElement: { + /** @enum {string} */ + type?: "signature" | "initial" | "date" | "datetime" | "text"; + /** Format: int64 */ + elementId?: number; + /** Format: int64 */ + nodeId?: number; + starred?: boolean; + file?: components["schemas"]["SavedSignatureFile"]; + coordinates?: components["schemas"]["Coordinate"]; + }; + SavedSignatureFile: { + base64?: string; + url?: string; + }; Settings: { canSign: boolean; canRequestSign: boolean; @@ -1825,6 +1861,12 @@ export type components = { statusText: string; participantRole?: components["schemas"]["ParticipantRole"]; }; + SigningElement: { + /** Format: int64 */ + documentElementId: number; + /** Format: int64 */ + profileNodeId?: number; + }; SigningJob: { /** @enum {string} */ status: "SIGNING_IN_PROGRESS"; @@ -1840,6 +1882,9 @@ export type components = { [key: string]: components["schemas"]["UserPolicyState"]; }; }; + UserConfigValue: string | boolean | number | Record[] | { + [key: string]: Record; + }; UserElement: { /** Format: int64 */ id: number; @@ -2572,7 +2617,14 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + /** @description Config value as a JSON string, number, boolean, list or nested object. Null is not a storable setting. */ + value: components["schemas"]["UserConfigValue"]; + }; + }; + }; responses: { /** @description Config updated */ 200: { @@ -4971,13 +5023,9 @@ export interface operations { "application/json": { /** * @description Values to persist for the group, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether users and requests below this group may override each saved value, keyed by policy identifier. * @default {} @@ -5066,13 +5114,9 @@ export interface operations { "application/json": { /** * @description Values to persist as the current user's defaults, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; }; }; }; @@ -5142,13 +5186,9 @@ export interface operations { "application/json": { /** * @description Values to persist for the target user, keyed by policy identifier. - * @default {} + * @default [] */ - values?: { - [key: string]: (boolean | number | string | { - [key: string]: Record; - }) | null; - }; + values?: components["schemas"]["PolicyValueMap"]; /** * @description Whether the target user may still override each assigned value, keyed by policy identifier. * @default {} @@ -5400,9 +5440,7 @@ export interface operations { * @description Structured policy payload with request-level overrides and active context. * @default null */ - policy?: { - [key: string]: Record; - } | null; + policy?: components["schemas"]["RequestPolicy"] | null; }; }; }; @@ -5496,9 +5534,7 @@ export interface operations { * @description Structured policy payload with request-level overrides and active context. * @default null */ - policy?: { - [key: string]: Record; - } | null; + policy?: components["schemas"]["RequestPolicy"] | null; /** * @description The name of file to sign * @default null @@ -5650,11 +5686,9 @@ export interface operations { method: string; /** * @description List of visible elements - * @default {} + * @default [] */ - elements?: { - [key: string]: Record; - }; + elements?: components["schemas"]["SigningElement"][]; /** * @description Identify value * @default @@ -5671,12 +5705,10 @@ export interface operations { */ async?: boolean; /** - * @description Device-reported geolocation metadata submitted by the signing client - * @default {} + * @description Optional device geolocation, omitted when absent; legacy [] is accepted + * @default null */ - deviceGeolocation?: { - [key: string]: Record; - }; + deviceGeolocation?: components["schemas"]["SignerDeviceGeolocation"] | null; }; }; }; @@ -5797,11 +5829,9 @@ export interface operations { method: string; /** * @description List of visible elements - * @default {} + * @default [] */ - elements?: { - [key: string]: Record; - }; + elements?: components["schemas"]["SigningElement"][]; /** * @description Identify value * @default @@ -5818,12 +5848,10 @@ export interface operations { */ async?: boolean; /** - * @description Device-reported geolocation metadata submitted by the signing client - * @default {} + * @description Optional device geolocation, omitted when absent; legacy [] is accepted + * @default null */ - deviceGeolocation?: { - [key: string]: Record; - }; + deviceGeolocation?: components["schemas"]["SignerDeviceGeolocation"] | null; /** * @description Explicitly save the external email signer's visible signature for future documents * @default false @@ -6050,18 +6078,20 @@ export interface operations { content: { "application/json": { /** - * @description Identify signer method - * @enum {string|null} + * @description Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation + * @default null + */ + identifyMethod?: string | null; + /** + * @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken + * @default null */ - identifyMethod?: "account" | "email" | null; - /** @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken */ signMethod?: string | null; /** - * @description Literal string "true" when the UUID is an identification-document file UUID (matches middleware RequireSigner) + * @description Only the literal "true" selects the identification-document approval context. Query or JSON body is accepted. * @default null - * @enum {string|null} */ - idDocApproval?: "true" | null; + idDocApproval?: string | null; }; }; }; @@ -6116,11 +6146,14 @@ export interface operations { content: { "application/json": { /** - * @description Identify signer method - * @enum {string|null} + * @description Configured signer identification method (for example account, email or sms); omitted methods remain subject to service validation + * @default null + */ + identifyMethod?: string | null; + /** + * @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken + * @default null */ - identifyMethod?: "account" | "email" | null; - /** @description Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken */ signMethod?: string | null; }; }; @@ -6219,10 +6252,8 @@ export interface operations { requestBody: { content: { "application/json": { - /** @description Element object */ - elements: { - [key: string]: Record; - }; + /** @description Saved signature elements */ + elements: components["schemas"]["SavedSignatureElement"][]; }; }; }; @@ -6489,12 +6520,10 @@ export interface operations { */ type?: string; /** - * @description Element object - * @default {} + * @description Signature image supplied as base64 or URL + * @default [] */ - file?: { - [key: string]: Record; - }; + file?: components["schemas"]["SavedSignatureFile"]; }; }; }; diff --git a/tests/php/Unit/Controller/AccountControllerConfigTest.php b/tests/php/Unit/Controller/AccountControllerConfigTest.php new file mode 100644 index 0000000000..f4c644a910 --- /dev/null +++ b/tests/php/Unit/Controller/AccountControllerConfigTest.php @@ -0,0 +1,85 @@ + ['date', 'date'], + 'boolean true' => [true, '1'], + 'boolean false' => [false, '0'], + 'integer' => [4, '4'], + 'fractional number' => [1.25, '1.25'], + 'empty list' => [[], '[]'], + 'nested JSON' => [['advanced' => true, 'filters' => ['ids' => [1, null, 'a'], 'hidden' => false]], '{"advanced":true,"filters":{"ids":[1,null,"a"],"hidden":false}}'], + ]; + } + + #[DataProvider('configValues')] + public function testConfigValuesAreStoredAsStrings(mixed $input, string $stored): void { + $config = $this->createMock(IUserConfig::class); + $config->expects($this->once()) + ->method('setValueString') + ->with('account-test', 'libresign', 'setting', $stored) + ->willReturn(true); + $response = $this->controller($config)->setConfig('setting', $input); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['key' => 'setting', 'value' => $stored], $response->getData()); + } + + public function testExplicitNullDoesNotWriteAConfigValue(): void { + $config = $this->createMock(IUserConfig::class); + $config->expects($this->never())->method('setValueString'); + $response = $this->controller($config)->setConfig('setting', null); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + private function controller(IUserConfig $config): AccountController { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('account-test'); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + + return new AccountController( + $this->createMock(IRequest::class), + $this->createMock(IL10N::class), + $this->createMock(IAccountManager::class), + $this->createMock(AccountService::class), + $this->createMock(SignFileService::class), + $this->createMock(SignerElementsService::class), + $this->createMock(Pkcs12Handler::class), + $this->createMock(Chain::class), + $this->createMock(IURLGenerator::class), + $this->createMock(LoggerInterface::class), + $userSession, + $this->createMock(SessionService::class), + $config, + ); + } +} diff --git a/tests/php/Unit/Controller/SignFileControllerTest.php b/tests/php/Unit/Controller/SignFileControllerTest.php index b60793c785..94509fe2a3 100644 --- a/tests/php/Unit/Controller/SignFileControllerTest.php +++ b/tests/php/Unit/Controller/SignFileControllerTest.php @@ -13,6 +13,7 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\SigningErrorHandler; use OCA\Libresign\Service\AsyncSigningService; use OCA\Libresign\Service\EmailSignerElementsService; @@ -34,6 +35,7 @@ use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -51,6 +53,7 @@ final class SignFileControllerTest extends TestCase { private WorkerConfiguration&MockObject $workerConfiguration; private AsyncSigningService&MockObject $asyncSigningService; private SignatureRejectionService&MockObject $signatureRejectionService; + private SignerGeolocationMetadataValidator&MockObject $signerGeolocationMetadataValidator; protected function setUp(): void { $this->request = $this->createMock(IRequest::class); @@ -66,6 +69,23 @@ protected function setUp(): void { $this->workerConfiguration = $this->createMock(WorkerConfiguration::class); $this->asyncSigningService = $this->createMock(AsyncSigningService::class); $this->signatureRejectionService = $this->createMock(SignatureRejectionService::class); + $this->signerGeolocationMetadataValidator = $this->createMock(SignerGeolocationMetadataValidator::class); + } + + public function testSignerCodeRequestAllowsOmittedOptionalMethods(): void { + $this->signRequestMapper->method('getBySignerUuidAndUserId') + ->willThrowException(new \RuntimeException('not found')); + $this->expectException(LibresignException::class); + $this->expectExceptionMessage('Invalid data to sign file'); + $this->getController()->requestCodeBySignerUuid('missing-uuid'); + } + + public function testFileCodeRequestAllowsOmittedOptionalMethods(): void { + $this->signRequestMapper->method('getByFileIdAndUserId') + ->willThrowException(new \RuntimeException('not found')); + $this->expectException(LibresignException::class); + $this->expectExceptionMessage('Invalid data to sign file'); + $this->getController()->requestCodeByFileId(123); } private function getController(): SignFileController { @@ -85,7 +105,7 @@ private function getController(): SignFileController { $this->workerConfiguration, $this->asyncSigningService, $this->createMock(RequestMetadataService::class), - $this->createMock(SignerGeolocationMetadataValidator::class), + $this->signerGeolocationMetadataValidator, $this->createMock(SignerIpGeolocationPolicyService::class), $this->createMock(SigningErrorHandler::class), $this->signatureRejectionService, @@ -99,9 +119,18 @@ private function getController(): SignFileController { * the identification document, not with a signer uuid. The code must go * to the approver's own sign request, resolved the same way sign() does. */ - public function testRequestCodeBySignerUuidInIdDocApprovalContextUsesTheApproverSignRequest(): void { + public static function idDocApprovalSources(): array { + return [ + 'query string' => [null, 'true'], + 'JSON body' => ['true', null], + ]; + } + + #[DataProvider('idDocApprovalSources')] + public function testRequestCodeBySignerUuidInIdDocApprovalContextUsesTheApproverSignRequest(?string $bodyValue, ?string $queryValue): void { $approver = $this->createMock(IUser::class); $this->userSession->method('getUser')->willReturn($approver); + $this->request->method('getParam')->with('idDocApproval')->willReturn($queryValue); $idDoc = new FileEntity(); $idDoc->setId(10); @@ -125,7 +154,7 @@ public function testRequestCodeBySignerUuidInIdDocApprovalContextUsesTheApprover ->method('requestCode') ->with($approverSignRequest, 'account', 'emailToken'); - $response = $this->getController()->requestCodeBySignerUuid('file-uuid', 'account', 'emailToken', 'true'); + $response = $this->getController()->requestCodeBySignerUuid('file-uuid', 'account', 'emailToken', $bodyValue); $this->assertSame(Http::STATUS_OK, $response->getStatus()); $this->assertSame('Verification code sent.', $response->getData()['message']); @@ -154,6 +183,46 @@ public function testRequestCodeBySignerUuidWithoutIdDocApprovalKeepsTheSignerLoo $this->assertSame(Http::STATUS_OK, $response->getStatus()); } + public function testSignerUuidCodeRequestForSmsUsesConfiguredMethod(): void { + $this->request->method('getParam')->with('idDocApproval')->willReturn('false'); + $signRequest = new SignRequest(); + $signRequest->setId(5); + $signRequest->setFileId(3); + $file = new FileEntity(); + $file->setId(3); + $this->signRequestMapper->expects($this->once()) + ->method('getBySignerUuidAndUserId') + ->with('signer-uuid') + ->willReturn($signRequest); + $this->signFileService->expects($this->never())->method('getFileByUuid'); + $this->signFileService->method('getFile')->with(3)->willReturn($file); + $this->signFileService->expects($this->once()) + ->method('requestCode') + ->with($signRequest, 'sms', 'smsToken'); + + $response = $this->getController()->requestCodeBySignerUuid('signer-uuid', 'sms', 'smsToken'); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + + public function testFileIdCodeRequestForSmsUsesConfiguredMethod(): void { + $signRequest = new SignRequest(); + $signRequest->setId(5); + $signRequest->setFileId(3); + $file = new FileEntity(); + $file->setId(3); + $this->signRequestMapper->expects($this->once()) + ->method('getByFileIdAndUserId') + ->with(3) + ->willReturn($signRequest); + $this->signFileService->method('getFile')->with(3)->willReturn($file); + $this->signFileService->expects($this->once()) + ->method('requestCode') + ->with($signRequest, 'sms', 'smsToken'); + + $response = $this->getController()->requestCodeByFileId(3, 'sms', 'smsToken'); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + public function testRequestCodeEndpointsDoNotExposeIdentifyDestinationParameter(): void { $uuidMethod = new \ReflectionMethod( SignFileController::class, @@ -293,6 +362,31 @@ private function prepareSigning(bool $prepareSignature = true): SignRequest { return $signRequest; } + public function testSigningNormalizesOmittedAndLegacyEmptyGeolocation(): void { + $this->prepareSigning(); + $this->signerGeolocationMetadataValidator->expects($this->exactly(3)) + ->method('normalize')->with(null)->willReturn(null); + $controller = $this->getController(); + $this->assertSame(Http::STATUS_OK, $controller->signBySignerUuid('signer-uuid', 'email')->getStatus()); + $this->assertSame(Http::STATUS_OK, $controller->signBySignerUuid('signer-uuid', 'email', deviceGeolocation: [])->getStatus()); + $this->assertSame(Http::STATUS_OK, $controller->signByFileId(3, 'email')->getStatus()); + } + + public function testSigningPreservesStructuredGeolocationForValidator(): void { + $this->prepareSigning(); + $geolocation = ['status' => 'collected', 'latitude' => -23.5, 'longitude' => -46.6]; + $this->signerGeolocationMetadataValidator->expects($this->once()) + ->method('normalize')->with($geolocation)->willReturn($geolocation); + $this->signerGeolocationMetadataValidator->expects($this->once()) + ->method('validateSubmission') + ->with($this->isInstanceOf(SignRequest::class), $geolocation); + $this->assertSame(Http::STATUS_OK, $this->getController()->signBySignerUuid( + 'signer-uuid', + 'email', + deviceGeolocation: $geolocation, + )->getStatus()); + } + public function testSigningWithoutConsentDoesNotSaveEmailSignature(): void { $this->prepareSigning(); $this->emailSignerElementsService->expects($this->never())->method('prepareSignature'); diff --git a/tests/php/openapi/OpenApiSigningRequestContractsTest.php b/tests/php/openapi/OpenApiSigningRequestContractsTest.php new file mode 100644 index 0000000000..9eba4603de --- /dev/null +++ b/tests/php/openapi/OpenApiSigningRequestContractsTest.php @@ -0,0 +1,221 @@ + ['sign_file-sign-by-file-id'], + 'signer UUID' => ['sign_file-sign-by-signer-uuid'], + ]; + } + + #[DataProvider('signingOperations')] + public function testSigningElementsAreAnOptionalTypedList(string $operationId): void { + $spec = self::loadSpec('openapi-full.json'); + $operation = self::operation($spec, $operationId); + $schema = $operation['requestBody']['content']['application/json']['schema']; + self::assertNotContains('elements', $schema['required']); + $elements = $schema['properties']['elements']; + self::assertSame('array', $elements['type']); + self::assertSame([], $elements['default']); + self::assertSame('#/components/schemas/SigningElement', $elements['items']['$ref']); + $item = $spec['components']['schemas']['SigningElement']; + self::assertSame(['documentElementId'], $item['required']); + self::assertSame('integer', $item['properties']['documentElementId']['type']); + self::assertSame('integer', $item['properties']['profileNodeId']['type']); + } + + #[DataProvider('signingOperations')] + public function testSigningGeolocationHasTypedStatusAndOptionalFields(string $operationId): void { + $spec = self::loadSpec('openapi-full.json'); + $geolocation = self::requestProperty($spec, $operationId, 'deviceGeolocation'); + self::assertNull($geolocation['default']); + self::assertTrue($geolocation['nullable']); + self::assertSame([['$ref' => '#/components/schemas/SignerDeviceGeolocation']], $geolocation['allOf']); + self::assertArrayNotHasKey('anyOf', $geolocation); + $device = $spec['components']['schemas']['SignerDeviceGeolocation']; + self::assertSame(['status'], $device['required']); + self::assertSame('#/components/schemas/GeolocationCollectionStatus', $device['properties']['status']['$ref']); + foreach (['latitude', 'longitude', 'accuracy'] as $name) { + self::assertSame('number', $device['properties'][$name]['type']); + } + self::assertSame('integer', $device['properties']['timestamp']['type']); + } + + public function testSavedSignatureCreationAcceptsTypedList(): void { + $spec = self::loadSpec('openapi-full.json'); + $elements = self::requestProperty($spec, 'signature_elements-create-signature-element', 'elements'); + self::assertSame('array', $elements['type']); + self::assertSame('#/components/schemas/SavedSignatureElement', $elements['items']['$ref']); + $item = $spec['components']['schemas']['SavedSignatureElement']; + self::assertSame(['signature', 'initial', 'date', 'datetime', 'text'], $item['properties']['type']['enum']); + self::assertSame('#/components/schemas/SavedSignatureFile', $item['properties']['file']['$ref']); + foreach (['elementId', 'nodeId'] as $field) { + self::assertSame('integer', $item['properties'][$field]['type']); + } + } + + public function testSavedSignatureUpdateUsesTypedFile(): void { + $spec = self::loadSpec('openapi-full.json'); + $file = self::requestProperty($spec, 'signature_elements-patch-signature-element', 'file'); + self::assertSame('#/components/schemas/SavedSignatureFile', $file['$ref']); + $definition = $spec['components']['schemas']['SavedSignatureFile']; + foreach (['base64', 'url'] as $field) { + self::assertSame('string', $definition['properties'][$field]['type']); + } + self::assertArrayNotHasKey('required', $definition); + } + + /** @return array */ + public static function requestPolicyOperations(): array { + return [ + 'new request' => ['request_signature-request-signature'], + 'updated request' => ['request_signature-update-signature-request'], + ]; + } + + #[DataProvider('requestPolicyOperations')] + public function testRequestPolicyReflectsOverridesAndActiveContext(string $operationId): void { + $spec = self::loadSpec('openapi-full.json'); + $policy = self::requestProperty($spec, $operationId, 'policy'); + self::assertTrue($policy['nullable']); + self::assertNull($policy['default']); + self::assertSame([['$ref' => '#/components/schemas/RequestPolicy']], $policy['allOf']); + $request = $spec['components']['schemas']['RequestPolicy']; + self::assertSame('object', $request['type']); + self::assertArrayNotHasKey('required', $request); + self::assertSame('#/components/schemas/RequestPolicyOverrides', $request['properties']['overrides']['$ref']); + self::assertSame('#/components/schemas/PolicyActiveContext', $request['properties']['activeContext']['$ref']); + $overrides = $spec['components']['schemas']['RequestPolicyOverrides']; + self::assertSame('object', $overrides['type']); + $branches = $overrides['additionalProperties']['anyOf']; + self::assertContains('#/components/schemas/EffectivePolicyValue', array_column($branches, '$ref')); + self::assertContains('array', array_column($branches, 'type')); + $activeContext = $spec['components']['schemas']['PolicyActiveContext']; + self::assertSame(['type', 'id'], $activeContext['required']); + self::assertSame(['group'], $activeContext['properties']['type']['enum']); + self::assertSame(1, $activeContext['properties']['id']['minLength']); + } + + /** @return array */ + public static function signingCodeOperations(): array { + return [ + 'signer UUID' => ['sign_file-request-code-by-signer-uuid'], + 'file ID' => ['sign_file-request-code-by-file-id'], + ]; + } + + #[DataProvider('signingCodeOperations')] + public function testSigningCodeIdentificationSupportsConfiguredMethodsAndNull(string $operationId): void { + $spec = self::loadSpec('openapi-full.json'); + $body = self::operation($spec, $operationId)['requestBody']; + self::assertFalse($body['required']); + $schema = $body['content']['application/json']['schema']; + self::assertNotContains('identifyMethod', $schema['required'] ?? []); + $identifyMethod = $schema['properties']['identifyMethod']; + self::assertSame('string', $identifyMethod['type']); + self::assertTrue($identifyMethod['nullable']); + self::assertNull($identifyMethod['default']); + self::assertArrayNotHasKey('enum', $identifyMethod, 'Configured methods must not be limited to account/email'); + $signMethod = $schema['properties']['signMethod']; + self::assertSame('string', $signMethod['type']); + self::assertTrue($signMethod['nullable']); + self::assertNull($signMethod['default']); + } + + public function testIdDocApprovalDocumentsTheBodyFlagAndFrontendQuery(): void { + $spec = self::loadSpec('openapi-full.json'); + $operation = self::operation($spec, 'sign_file-request-code-by-signer-uuid'); + $schema = $operation['requestBody']['content']['application/json']['schema']; + $flag = $schema['properties']['idDocApproval']; + self::assertSame('string', $flag['type']); + self::assertTrue($flag['nullable']); + self::assertNull($flag['default']); + self::assertArrayNotHasKey('enum', $flag); + self::assertStringContainsString('?idDocApproval=true', $operation['description'] ?? ''); + + $signOperation = self::operation($spec, 'sign_file-sign-by-signer-uuid'); + self::assertArrayNotHasKey('idDocApproval', $signOperation['requestBody']['content']['application/json']['schema']['properties']); + self::assertStringContainsString('?idDocApproval=true', $signOperation['description'] ?? ''); + } + + public function testAccountConfigValueUsesARequiredHeterogeneousJsonContract(): void { + $spec = self::loadSpec('openapi-full.json'); + $operation = self::operation($spec, 'account-set-config'); + self::assertTrue($operation['requestBody']['required']); + $schema = $operation['requestBody']['content']['application/json']['schema']; + self::assertSame(['value'], $schema['required']); + self::assertSame('#/components/schemas/UserConfigValue', $schema['properties']['value']['$ref']); + + $configValue = $spec['components']['schemas']['UserConfigValue']; + $branches = $configValue['anyOf'] ?? $configValue['oneOf'] ?? []; + self::assertContains('boolean', array_column($branches, 'type')); + self::assertContains('string', array_column($branches, 'type')); + self::assertContains('number', array_column($branches, 'type')); + self::assertContains('object', array_column($branches, 'type')); + self::assertContains('array', array_column($branches, 'type')); + self::assertFalse($configValue['nullable'] ?? false); + } + + /** @return array */ + public static function compoundPolicyOperations(): array { + return [ + 'system' => ['policy-set-system-compound'], + 'group' => ['policy-set-group-compound'], + 'own user' => ['policy-set-user-preference-compound'], + 'target user' => ['policy-set-user-policy-for-user-compound'], + ]; + } + + #[DataProvider('compoundPolicyOperations')] + public function testCompoundPoliciesReferenceSharedValueMap(string $operationId): void { + $spec = self::loadSpec('openapi-full.json'); + $values = self::requestProperty($spec, $operationId, 'values'); + self::assertSame('#/components/schemas/PolicyValueMap', $values['$ref']); + $map = $spec['components']['schemas']['PolicyValueMap']; + self::assertSame('object', $map['type']); + self::assertSame('#/components/schemas/EffectivePolicyValue', $map['additionalProperties']['$ref']); + } + + public function testExistingUnmodifiedOperationsRemainExposed(): void { + $spec = self::loadSpec('openapi-full.json'); + foreach (['account-me', 'account-set-config', 'certificate_policy-get-certificate-policy'] as $operationId) { + self::assertNotEmpty(self::operation($spec, $operationId)); + } + } +} diff --git a/tests/php/openapi/phpunit.xml b/tests/php/openapi/phpunit.xml index f070551adf..c9ec9062ea 100644 --- a/tests/php/openapi/phpunit.xml +++ b/tests/php/openapi/phpunit.xml @@ -17,6 +17,7 @@ OpenApiGenerationTest.php OpenApiPolicyNumberUnionTest.php OpenApiRequestHeadersTest.php + OpenApiSigningRequestContractsTest.php diff --git a/tests/sdk-generation/package.json b/tests/sdk-generation/package.json index 63f580cc43..f28958bec6 100644 --- a/tests/sdk-generation/package.json +++ b/tests/sdk-generation/package.json @@ -4,7 +4,7 @@ "private": true, "license": "AGPL-3.0-or-later", "scripts": { - "compile": "tsc --noEmit --strict --skipLibCheck --target ES2020 --module commonjs --moduleResolution node ../../build/sdk-generation/typescript/*.ts types.ts transport.ts live.ts live.test.ts", + "compile": "tsc --noEmit --strict --skipLibCheck --target ES2020 --module commonjs --moduleResolution node ../../build/sdk-generation/typescript/*.ts types.ts signing-contracts.ts transport.ts live.ts live.test.ts", "test": "tsx transport.ts && node inventory.cjs && tsx --test live.test.ts && node --test generate.test.cjs", "test:anyof": "tsc --noEmit --strict --skipLibCheck --target ES2020 --module commonjs --moduleResolution node anyof-defect.ts", "test:live": "tsx live.ts", diff --git a/tests/sdk-generation/signing-contracts.ts b/tests/sdk-generation/signing-contracts.ts new file mode 100644 index 0000000000..65fdc7e112 --- /dev/null +++ b/tests/sdk-generation/signing-contracts.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { + SignFileSignByFileIdRequest, + SignFileSignBySignerUuidRequest, + SignatureElementsCreateSignatureElementRequest, + SignatureElementsPatchSignatureElementRequest, +} from '../../build/sdk-generation/typescript/api' + +const sign: SignFileSignByFileIdRequest = { + method: 'clickToSign', + elements: [ + { documentElementId: 100, profileNodeId: 42 }, + { documentElementId: 101 }, + ], + deviceGeolocation: { status: 'collected', latitude: 10, longitude: -20, accuracy: 15, timestamp: 123 }, +} +const external: SignFileSignBySignerUuidRequest = { method: 'clickToSign', elements: [], saveSignature: true } +const create: SignatureElementsCreateSignatureElementRequest = { + elements: [{ type: 'signature', file: { base64: 'synthetic-data' } }, { type: 'initial', file: { url: 'https://example.test/image.png' } }], +} +const update: SignatureElementsPatchSignatureElementRequest = { type: 'signature', file: { base64: 'synthetic-data' } } + +// @ts-expect-error elements must be a list, not an object map +const badSignElements: SignFileSignByFileIdRequest = { method: 'clickToSign', elements: { documentElementId: 100 } } +// @ts-expect-error documentElementId is required in each list item +const missingDocumentElement: SignFileSignByFileIdRequest = { method: 'clickToSign', elements: [{ profileNodeId: 42 }] } +// @ts-expect-error profileNodeId must be numeric +const badProfile: SignFileSignBySignerUuidRequest = { method: 'clickToSign', elements: [{ documentElementId: 100, profileNodeId: '42' }] } +// @ts-expect-error signature elements must be a list +const badCreate: SignatureElementsCreateSignatureElementRequest = { elements: { type: 'signature' } } +// @ts-expect-error unsupported signature type +const badType: SignatureElementsCreateSignatureElementRequest = { elements: [{ type: 'unknown' }] } +// @ts-expect-error saved image data must be a string +const badFile: SignatureElementsPatchSignatureElementRequest = { file: { base64: 100 } } +// @ts-expect-error location status must be a supported status +const badGeo: SignFileSignByFileIdRequest = { method: 'clickToSign', deviceGeolocation: { status: 'unknown' } } + +void [sign, external, create, update, badSignElements, missingDocumentElement, badProfile, badCreate, badType, badFile, badGeo] diff --git a/tests/sdk-generation/transport.ts b/tests/sdk-generation/transport.ts index 87014cc3d3..0434750373 100644 --- a/tests/sdk-generation/transport.ts +++ b/tests/sdk-generation/transport.ts @@ -4,7 +4,7 @@ */ import * as assert from 'node:assert/strict' import axios, { AxiosError, type AxiosAdapter, type InternalAxiosRequestConfig } from 'axios' -import { AppearanceApi, CertificatesApi, DocumentsApi, IdentityApi, PoliciesApi, RequestsApi } from '../../build/sdk-generation/typescript/api' +import { AppearanceApi, CertificatesApi, DocumentsApi, IdentityApi, PoliciesApi, RequestsApi, SigningApi } from '../../build/sdk-generation/typescript/api' import { Configuration } from '../../build/sdk-generation/typescript/configuration' async function run() { @@ -42,6 +42,43 @@ async function run() { assert.equal(last.url, 'https://example.test/nextcloud/ocs/v2.php/apps/libresign/api/v1/request-signature') assert.match(String(last.headers.get('Content-Type')), /application\/json/) assert.deepEqual(JSON.parse(String(last.data)), { name: 'Example', file: { nodeId: '123' } }) + const signing = new SigningApi(configuration, undefined, instance) + await signing.signFileSignByFileId({ + apiVersion: 'v1', + fileId: 33, + oCSAPIRequest: true, + signFileSignByFileIdRequest: { + method: 'clickToSign', + elements: [{ documentElementId: 100, profileNodeId: 42 }, { documentElementId: 101 }], + deviceGeolocation: { status: 'collected', latitude: 10, longitude: -20 }, + }, + }) + assert.equal(last.url, 'https://example.test/nextcloud/ocs/v2.php/apps/libresign/api/v1/sign/file_id/33') + assert.equal(last.method, 'post') + assert.equal(last.headers.get('OCS-APIRequest'), 'true') + assert.match(String(last.headers.get('Content-Type')), /application\/json/) + assert.deepEqual(JSON.parse(String(last.data)), { + method: 'clickToSign', + elements: [{ documentElementId: 100, profileNodeId: 42 }, { documentElementId: 101 }], + deviceGeolocation: { status: 'collected', latitude: 10, longitude: -20 }, + }) + const appearance = new AppearanceApi(configuration, undefined, instance) + await appearance.signatureElementsCreateSignatureElement({ + apiVersion: 'v1', + oCSAPIRequest: true, + signatureElementsCreateSignatureElementRequest: { elements: [{ type: 'signature', file: { base64: 'synthetic-data' } }] }, + }) + assert.equal(last.url, 'https://example.test/nextcloud/ocs/v2.php/apps/libresign/api/v1/signature/elements') + assert.deepEqual(JSON.parse(String(last.data)), { elements: [{ type: 'signature', file: { base64: 'synthetic-data' } }] }) + await appearance.signatureElementsPatchSignatureElement({ + apiVersion: 'v1', + nodeId: 42, + oCSAPIRequest: true, + signatureElementsPatchSignatureElementRequest: { type: 'initial', file: { url: 'https://example.test/initial.png' } }, + }) + assert.equal(last.url, 'https://example.test/nextcloud/ocs/v2.php/apps/libresign/api/v1/signature/elements/42') + assert.equal(last.method, 'patch') + assert.deepEqual(JSON.parse(String(last.data)), { type: 'initial', file: { url: 'https://example.test/initial.png' } }) const certificates = new CertificatesApi(configuration, undefined, instance) body = new Uint8Array([37, 80, 68, 70]) const pdf = await certificates.certificatePolicyGetCertificatePolicy({ responseType: 'arraybuffer' })