fix(docsync): a paused binding no longer resets its own settings

PATCH links validated its body with a schema derived from the create
contract through .partial(). In Zod 4 a .default() still fires inside an
optional field, so a patch naming one setting arrived with every other
one filled in: pausing a pull-only binding switched it to two-way, reset
both policies and blanked its label. The update contract is now written
out without defaults, and an e2e test drives a patch through the real
pipe, which the controller unit tests never see.

Also, the cosmetic leftovers from the review:
- POST connections/:connectionId/scopes no longer wants the connection
  in the body as well. It was required, never read, and let one request
  name two connections; a client that still sends it has the key
  stripped rather than refused.
- createLink loses the scope argument every caller passed as null.
- The controller's owner check is now assertCanManage, because it lets
  instance admins through just as canManageDocSync does on the client.
  Who may manage a binding is unchanged.
- document_provider_fields drops settings_key and payload_key. Nothing
  read them: the connect form takes its fields from /docsync/providers
  and the admin shelf sends none for document providers.
- The e2e test imports the provider classes under their new names.
This commit is contained in:
Maurice
2026-09-19 12:06:06 +02:00
parent 01a7224f29
commit b88b92add7
11 changed files with 243 additions and 71 deletions
+20 -3
View File
@@ -105,9 +105,13 @@ export type DocsyncScopeOption = z.infer<typeof docsyncScopeOptionSchema>;
* Creating a scope rather than picking one. Only providers with a container
* concept accept this (a WebDAV folder, an OpenCloud space, a Paperless tag,
* a Papra tag); the adapter reports whether it can.
*
* The connection comes from the path (`connections/:connectionId/scopes`) and
* is not repeated here. A copy in the body was never read and only let one
* request name two different connections; a client that still sends it has
* the key stripped rather than refused.
*/
export const docsyncScopeCreateSchema = z.object({
connectionId: idSchema,
/** Name for the new folder, tag or space. */
name: z.string().trim().min(1).max(200),
});
@@ -155,9 +159,22 @@ export type DocsyncLinkInput = z.infer<typeof docsyncLinkInputSchema>;
* it would leave them all pointing somewhere else. The root fields are left out
* rather than ignored — they were accepted and silently dropped, so a client
* sending one was told the move had happened.
*
* Spelled out instead of derived from the input schema with `.partial()`. In
* Zod 4 a `.default()` still fires inside an optional field, so the derived
* version filled in every default the patch left out: `{ syncEnabled: false }`
* arrived as a full set of settings and reset direction, both policies and the
* label with it. A pull-only binding turned two-way the moment somebody paused
* it.
*/
export const docsyncLinkUpdateSchema = docsyncLinkInputSchema
.omit({ connectionId: true, scopeKey: true, remoteRootId: true, remoteRootPath: true })
export const docsyncLinkUpdateSchema = z
.object({
remoteLabel: z.string().max(512),
direction: docsyncDirectionSchema,
deletePolicy: docsyncDeletePolicySchema,
conflictPolicy: docsyncConflictPolicySchema,
syncEnabled: z.boolean(),
})
.partial();
// ── Sync state ───────────────────────────────────────────────────────────────