Skip to content

Commit 1c9a61d

Browse files
authored
Merge pull request #7138 from LibreSign/backport/7135/stable33
[stable33] fix: files integration actions and propfind
2 parents 5dd4a30 + 88bba3e commit 1c9a61d

File tree

4 files changed

+411
-5
lines changed

4 files changed

+411
-5
lines changed

src/init.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import axios from '@nextcloud/axios'
77
import { addNewFileMenuEntry, Permission, getSidebar } from '@nextcloud/files'
88
import type { NewMenuEntry, IFolder, INode } from '@nextcloud/files'
99
import { registerDavProperty } from '@nextcloud/files/dav'
10-
import { getClient, resultToNode } from '@nextcloud/files/dav'
10+
import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'
11+
import { loadState } from '@nextcloud/initial-state'
1112
import { t } from '@nextcloud/l10n'
1213
import { generateOcsUrl } from '@nextcloud/router'
1314
import { getUploader } from '@nextcloud/upload'
1415
import type { Uploader } from '@nextcloud/upload'
1516
import type { FileStat, ResponseDataDetailed } from 'webdav'
1617

17-
import logger from './logger'
18+
import './actions/openInLibreSignAction.js'
19+
import './actions/showStatusInlineAction.js'
1820
import LibreSignLogoSvg from '../img/app-colored.svg?raw'
1921
import LibreSignLogoDarkSvg from '../img/app-dark.svg?raw'
2022
import { useIsDarkTheme } from './helpers/useIsDarkTheme'
@@ -40,6 +42,9 @@ addNewFileMenuEntry({
4042
uploadManager: getUploader(),
4143
order: 1,
4244
enabled(context: IFolder) {
45+
if (!loadState('libresign', 'certificate_ok', false)) {
46+
return false
47+
}
4348
return (context.permissions & Permission.CREATE) !== 0
4449
},
4550
async handler(this: ExtendedNewMenuEntry, context: IFolder, content: INode[]) {
@@ -64,14 +69,17 @@ addNewFileMenuEntry({
6469
name: file.name,
6570
})
6671

67-
// Fetch the complete node object from the Files API
72+
// Fetch the complete node object including Nextcloud-specific props (fileid, etc.)
6873
const client = getClient()
69-
const result = await client.stat(path, { details: true }) as ResponseDataDetailed<FileStat>
74+
const result = await client.stat(`${getRootPath()}${path}`, {
75+
details: true,
76+
data: getDefaultPropfind(),
77+
}) as ResponseDataDetailed<FileStat>
7078
const node = resultToNode(result.data)
7179

7280
// Open sidebar with LibreSign tab
7381
const sidebar = getSidebar()
74-
sidebar.open(node, 'libresign')
82+
await sidebar.open(node, 'libresign')
7583
sidebar.setActiveTab('libresign')
7684
}
7785

src/store/files.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,9 @@ export const useFilesStore = function(...args) {
406406
}
407407
},
408408
signerUpdate(signer) {
409+
if (!this.selectedFileId || !this.files[this.selectedFileId]) {
410+
return
411+
}
409412
this.addIdentifierToSigner(signer)
410413
if (!this.getFile().signers?.length) {
411414
this.getFile().signers = []

src/tests/init.spec.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 LibreSign contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'
7+
8+
/**
9+
* Regression tests for src/init.ts
10+
*
11+
* Bug 1: PROPFIND 404 on file upload via "New signature request" menu.
12+
* client.stat() was called with only the bare file path (e.g. "/folder/test.pdf"),
13+
* which resolves to /remote.php/dav/folder/test.pdf → 404.
14+
* Fix: path must be prefixed with getRootPath() (e.g. "/files/uid/folder/test.pdf").
15+
*
16+
* Bug 2: "Open in LibreSign" action was missing from Files context-menu.
17+
* The action files were never imported by any bundle entry point, so
18+
* registerFileAction() was never called for them.
19+
* Fix: init.ts now imports both action modules as side-effects.
20+
*
21+
* Bug 3: POST /request-signature missing "file" parameter after upload.
22+
* client.stat() was called WITHOUT getDefaultPropfind() data, so the WebDAV
23+
* PROPFIND response omitted the Nextcloud-specific {owncloud}fileid property.
24+
* resultToNode() produced a Node with fileid = undefined, mapNodeToFileInfo
25+
* returned id = '', addFile() silently rejected the temp record, selectedFileId
26+
* stayed 0, and saveOrUpdateSignatureRequest sent POST without any file reference.
27+
* Fix: pass data: getDefaultPropfind() to client.stat() so fileid is always
28+
* included and the sidebar can correctly identify the uploaded file.
29+
*/
30+
31+
// ─── Mocks ────────────────────────────────────────────────────────────────────
32+
33+
const mockDefaultPropfind = '<propfind xmlns="DAV:"><prop><fileid/></prop></propfind>'
34+
const mockGetDefaultPropfind = vi.fn(() => mockDefaultPropfind)
35+
36+
const mockStat = vi.fn()
37+
const mockClient = { stat: mockStat }
38+
const mockGetClient = vi.fn(() => mockClient)
39+
const mockGetRootPath = vi.fn(() => '/files/testuser')
40+
const mockResultToNode = vi.fn((data: unknown) => data)
41+
const mockRegisterDavProperty = vi.fn()
42+
43+
const mockSidebarOpen = vi.fn()
44+
const mockSidebarSetActiveTab = vi.fn()
45+
const mockSidebar = { open: mockSidebarOpen, setActiveTab: mockSidebarSetActiveTab }
46+
const mockGetSidebar = vi.fn(() => mockSidebar)
47+
const mockAddNewFileMenuEntry = vi.fn()
48+
const mockRegisterFileAction = vi.fn()
49+
50+
const mockUpload = vi.fn(() => Promise.resolve())
51+
const mockUploader = { upload: mockUpload }
52+
const mockGetUploader = vi.fn(() => mockUploader)
53+
54+
const mockAxiosPost = vi.fn(() => Promise.resolve({ data: {} }))
55+
56+
const mockLoadState = vi.fn((app: string, key: string, defaultValue?: unknown) => {
57+
if (app === 'libresign' && key === 'certificate_ok') return true
58+
return defaultValue
59+
})
60+
61+
// ─── Module-level mocks (hoisted before imports) ─────────────────────────────
62+
63+
vi.mock('@nextcloud/axios', () => ({
64+
default: { post: mockAxiosPost },
65+
}))
66+
67+
vi.mock('@nextcloud/router', () => ({
68+
generateOcsUrl: vi.fn((path: string) => `https://localhost${path}`),
69+
}))
70+
71+
vi.mock('@nextcloud/l10n', () => ({
72+
t: vi.fn((_app: string, text: string) => text),
73+
}))
74+
75+
vi.mock('@nextcloud/upload', () => ({
76+
getUploader: mockGetUploader,
77+
}))
78+
79+
vi.mock('@nextcloud/files', () => ({
80+
addNewFileMenuEntry: mockAddNewFileMenuEntry,
81+
getSidebar: mockGetSidebar,
82+
Permission: { CREATE: 4 },
83+
registerFileAction: mockRegisterFileAction,
84+
}))
85+
86+
vi.mock('@nextcloud/initial-state', () => ({
87+
loadState: mockLoadState,
88+
}))
89+
90+
vi.mock('@nextcloud/files/dav', () => ({
91+
getClient: mockGetClient,
92+
getDefaultPropfind: mockGetDefaultPropfind,
93+
getRootPath: mockGetRootPath,
94+
resultToNode: mockResultToNode,
95+
registerDavProperty: mockRegisterDavProperty,
96+
}))
97+
98+
// Stub the SVG imports so they don't blow up in the test environment
99+
vi.mock('../../img/app-colored.svg?raw', () => ({ default: '<svg/>' }))
100+
vi.mock('../../img/app-dark.svg?raw', () => ({ default: '<svg/>' }))
101+
102+
vi.mock('../helpers/useIsDarkTheme', () => ({
103+
useIsDarkTheme: vi.fn(() => false),
104+
}))
105+
106+
vi.mock('../logger', () => ({
107+
default: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
108+
}))
109+
110+
// Stub the action side-effect modules so they don't pull in unrelated deps,
111+
// but still allow us to assert they were imported (tested separately below).
112+
vi.mock('../actions/openInLibreSignAction.js', () => ({}))
113+
vi.mock('../actions/showStatusInlineAction.js', () => ({}))
114+
115+
// ─── Helpers ──────────────────────────────────────────────────────────────────
116+
117+
/**
118+
* Extract the handler that was registered via addNewFileMenuEntry so tests
119+
* can call it directly without simulating a real click.
120+
*/
121+
function captureNewMenuHandler(): (context: unknown, content: unknown) => Promise<void> {
122+
expect(mockAddNewFileMenuEntry).toHaveBeenCalledOnce()
123+
type MenuEntry = { handler: (context: unknown, content: unknown) => Promise<void>; uploadManager?: { upload: typeof mockUpload } }
124+
const entry = mockAddNewFileMenuEntry.mock.calls[0][0] as MenuEntry
125+
// Inject the mock uploader so handler can call this.uploadManager.upload()
126+
entry.uploadManager = mockUploader
127+
return entry.handler.bind(entry)
128+
}
129+
130+
/**
131+
* Intercept the <input type="file"> that the handler creates (it is never
132+
* appended to the DOM so document.querySelector cannot find it).
133+
* Returns an async trigger: call it after invoking the handler to simulate
134+
* the user picking a file.
135+
*/
136+
function setupFileInputInterception(fileName: string, mimeType = 'application/pdf') {
137+
let capturedInput: HTMLInputElement | null = null
138+
const originalCreate = document.createElement.bind(document)
139+
140+
vi.spyOn(document, 'createElement').mockImplementation((tag: string, ...args: unknown[]) => {
141+
const el = originalCreate(tag, ...(args as [ElementCreationOptions?]))
142+
if (tag === 'input') {
143+
capturedInput = el as HTMLInputElement
144+
}
145+
return el
146+
})
147+
148+
return async function triggerChange() {
149+
expect(capturedInput, 'handler must have called document.createElement("input")').not.toBeNull()
150+
151+
const file = new File(['%PDF-1.4'], fileName, { type: mimeType })
152+
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
153+
capturedInput!.dispatchEvent(new Event('change'))
154+
155+
await vi.waitFor(() => expect(mockStat).toHaveBeenCalled(), { timeout: 2000 })
156+
157+
vi.restoreAllMocks()
158+
}
159+
}
160+
161+
// ─── Tests ────────────────────────────────────────────────────────────────────
162+
163+
describe('init.ts', () => {
164+
beforeEach(async () => {
165+
vi.clearAllMocks()
166+
167+
// Reset stat mock to return valid data for resultToNode
168+
mockStat.mockResolvedValue({ data: { filename: '/files/testuser/Documents/test.pdf' } })
169+
170+
// Import init.ts – all side-effects run here
171+
await import('../init')
172+
})
173+
174+
afterEach(() => {
175+
vi.resetModules()
176+
})
177+
178+
// ── Side-effect: DAV properties ─────────────────────────────────────────
179+
180+
it('registers the libresign-signature-status DAV property', () => {
181+
expect(mockRegisterDavProperty).toHaveBeenCalledWith(
182+
'nc:libresign-signature-status',
183+
{ nc: 'http://nextcloud.org/ns' },
184+
)
185+
})
186+
187+
it('registers the libresign-signed-node-id DAV property', () => {
188+
expect(mockRegisterDavProperty).toHaveBeenCalledWith(
189+
'nc:libresign-signed-node-id',
190+
{ nc: 'http://nextcloud.org/ns' },
191+
)
192+
})
193+
194+
// ── Side-effect: new-file-menu entry ─────────────────────────────────────
195+
196+
it('adds a "New signature request" entry to the Files new-menu', () => {
197+
expect(mockAddNewFileMenuEntry).toHaveBeenCalledOnce()
198+
const entry = mockAddNewFileMenuEntry.mock.calls[0][0] as { id: string }
199+
expect(entry.id).toBe('libresign-request')
200+
})
201+
202+
/**
203+
* Regression: sidebar did not open on LibreSign tab.
204+
* The menu entry must be hidden when LibreSign's certificate is not
205+
* configured (certificate_ok = false), because isEnabled() in tab.ts also
206+
* checks certificate_ok and rejects unconfigured instances — causing the
207+
* sidebar to fall back to the default (Details) tab.
208+
*/
209+
describe('menu entry enabled() guard', () => {
210+
type MenuEntry = {
211+
enabled: (context: { permissions: number }) => boolean
212+
}
213+
214+
it('is enabled when certificate_ok is true and folder has CREATE permission', () => {
215+
mockLoadState.mockReturnValue(true)
216+
const entry = mockAddNewFileMenuEntry.mock.calls[0][0] as MenuEntry
217+
expect(entry.enabled({ permissions: 4 /* CREATE */ })).toBe(true)
218+
})
219+
220+
it('is disabled when certificate_ok is false (LibreSign not configured)', () => {
221+
mockLoadState.mockReturnValue(false)
222+
const entry = mockAddNewFileMenuEntry.mock.calls[0][0] as MenuEntry
223+
expect(entry.enabled({ permissions: 4 /* CREATE */ })).toBe(false)
224+
})
225+
226+
it('is disabled when folder lacks CREATE permission even with certificate_ok', () => {
227+
mockLoadState.mockReturnValue(true)
228+
const entry = mockAddNewFileMenuEntry.mock.calls[0][0] as MenuEntry
229+
expect(entry.enabled({ permissions: 0 })).toBe(false)
230+
})
231+
})
232+
233+
// ── Side-effect: file-action imports ─────────────────────────────────────
234+
235+
/**
236+
* Regression: missing context-menu actions.
237+
* Both action modules must be imported so their registerFileAction() side-effect runs.
238+
*/
239+
it('imports openInLibreSignAction side-effect module', async () => {
240+
const mod = await import('../actions/openInLibreSignAction.js')
241+
expect(mod).toBeDefined()
242+
})
243+
244+
it('imports showStatusInlineAction side-effect module', async () => {
245+
const mod = await import('../actions/showStatusInlineAction.js')
246+
expect(mod).toBeDefined()
247+
})
248+
249+
// ── Upload handler: DAV stat path ─────────────────────────────────────────
250+
251+
describe('new-menu handler', () => {
252+
const folderPath = '/Documents'
253+
const fileName = 'contract.pdf'
254+
const context = { path: folderPath, permissions: 4 /* CREATE */ }
255+
256+
beforeEach(async () => {
257+
const triggerChange = setupFileInputInterception(fileName)
258+
const handler = captureNewMenuHandler()
259+
await handler(context, [])
260+
await triggerChange()
261+
})
262+
263+
/**
264+
* Regression: PROPFIND 404.
265+
* client.stat() must include the WebDAV root path prefix (/files/{uid}),
266+
* otherwise the request resolves to /remote.php/dav/<filename> → 404.
267+
*/
268+
it('calls client.stat with getRootPath() prefix to avoid PROPFIND 404', () => {
269+
const expectedPath = `${mockGetRootPath()}${folderPath}/${fileName}`
270+
expect(mockStat).toHaveBeenCalledWith(expectedPath, expect.objectContaining({ details: true }))
271+
})
272+
273+
it('does NOT call client.stat with a bare path missing the root prefix', () => {
274+
const barePath = `${folderPath}/${fileName}`
275+
// Ensure the old (broken) path was never used
276+
expect(mockStat).not.toHaveBeenCalledWith(barePath, expect.anything())
277+
})
278+
279+
/**
280+
* Regression: POST /request-signature missing "file" parameter.
281+
* Without getDefaultPropfind(), the WebDAV PROPFIND response omits the
282+
* Nextcloud-specific fileid property. resultToNode() then returns a Node
283+
* with fileid = undefined, which propagates as an empty fileInfo.id through
284+
* the sidebar → addFile silently rejects the record → selectedFileId = 0 →
285+
* saveOrUpdateSignatureRequest sends POST with no file reference → 422.
286+
*/
287+
it('calls client.stat with getDefaultPropfind() data so fileid is returned', () => {
288+
expect(mockStat).toHaveBeenCalledWith(
289+
expect.any(String),
290+
expect.objectContaining({ data: mockDefaultPropfind }),
291+
)
292+
})
293+
294+
it('uploads the file before posting the OCS request', () => {
295+
const uploadCallOrder = mockUpload.mock.invocationCallOrder[0]
296+
const postCallOrder = mockAxiosPost.mock.invocationCallOrder[0]
297+
expect(uploadCallOrder).toBeLessThan(postCallOrder)
298+
})
299+
300+
it('posts to the LibreSign OCS file endpoint', () => {
301+
expect(mockAxiosPost).toHaveBeenCalledWith(
302+
expect.stringContaining('/apps/libresign/api/v1/file'),
303+
expect.objectContaining({ name: fileName }),
304+
)
305+
})
306+
307+
it('opens the sidebar to the LibreSign tab after upload', () => {
308+
expect(mockSidebarOpen).toHaveBeenCalledOnce()
309+
expect(mockSidebarSetActiveTab).toHaveBeenCalledWith('libresign')
310+
})
311+
})
312+
})

0 commit comments

Comments
 (0)