Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/RightSidebar/AppFilesTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ type PendingEnvelope = {
}

type FileInfo = {
id: number
// Nextcloud node id as tab.ts sends it: the numeric `fileid` when the node
// has one, otherwise the string `Node.id` of `@nextcloud/files`.
id: number | string
type?: string
name?: string
path?: string
Expand Down
22 changes: 20 additions & 2 deletions src/store/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,24 @@ const _filesStore = defineStore('files', () => {
.filter((signer) => signer && signer.identifyMethods?.length)
}

/**
* Whether a value identifies a Nextcloud node. Besides the historical
* positive number, `@nextcloud/files` exposes `Node.id` as a string and
* that is what the Files sidebar hands to AppFilesTab (#8363). The string
* is kept as is: node ids are 64-bit and converting them with Number()
* could change the value above Number.MAX_SAFE_INTEGER. The API accepts
* both representations.
*
* @param {unknown} value
* @return {value is number | string}
*/
function isNodeId(value) {
if (typeof value === 'number') {
return Number.isInteger(value) && value > 0
}
return typeof value === 'string' && /^[1-9][0-9]*$/.test(value)
}

/** @param {EditableFileReferenceDraft | ApiFileRecord | EditableFileDraft | string | null | undefined} file */
function serializeRequestFile(file, { preferNodeId = false } = {}) {
if (typeof file === 'string') {
Expand All @@ -937,7 +955,7 @@ const _filesStore = defineStore('files', () => {
if (typeof file.path === 'string' && file.path.length > 0) {
return { path: file.path }
}
if (preferNodeId && typeof file.nodeId === 'number' && file.nodeId > 0) {
if (preferNodeId && isNodeId(file.nodeId)) {
return { nodeId: file.nodeId }
}
if (typeof file.fileId === 'number' && file.fileId > 0) {
Expand All @@ -948,7 +966,7 @@ const _filesStore = defineStore('files', () => {
return { fileId: file.id }
}
}
if (typeof file.nodeId === 'number' && file.nodeId > 0) {
if (isNodeId(file.nodeId)) {
return { nodeId: file.nodeId }
}
if (typeof file.url === 'string' && file.url.length > 0) {
Expand Down
24 changes: 23 additions & 1 deletion src/tests/components/RightSidebar/AppFilesTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ type TitleObserver = {
}

type FileInfo = {
id: number
id: number | string
type?: string
name?: string
path?: string
Expand Down Expand Up @@ -286,6 +286,28 @@ describe('AppFilesTab', () => {
expect(sidebarStore.activeRequestSignatureTab).toHaveBeenCalled()
})

it('passes a string node id through unchanged when adding the file (#8363)', async () => {
filesStore.selectFileByNodeId = vi.fn().mockResolvedValue(null)
filesStore.addFile = vi.fn()
filesStore.selectFile = vi.fn()
sidebarStore.activeRequestSignatureTab = vi.fn()
wrapper = createWrapper()

// tab.ts sends `Node.id` (a string) when the node has no numeric fileid
await wrapper.vm.update({
id: '9007199254740993',
name: 'copy of contract.pdf',
path: '/Documents',
})

expect(filesStore.selectFileByNodeId).toHaveBeenCalledWith('9007199254740993')
expect(filesStore.addFile).toHaveBeenCalledWith(expect.objectContaining({
nodeId: '9007199254740993',
name: 'copy of contract.pdf',
}))
expect(sidebarStore.activeRequestSignatureTab).toHaveBeenCalled()
})

it('returns early when pending envelope processed', async () => {
window.OCA = {
Libresign: {
Expand Down
73 changes: 73 additions & 0 deletions src/tests/store/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,79 @@ describe('files store - critical business rules', () => {
expect(config.data.file).toEqual({ nodeId })
})

/**
* Regression #8363: `@nextcloud/files` exposes `Node.id` as a string and
* the Files sidebar hands it to AppFilesTab as is (a file copied in the
* Files app, or any node id above Number.MAX_SAFE_INTEGER). The store
* keeps it as nodeId; the request must carry it unchanged instead of
* dropping the whole "file" (422 "File or files parameter is required").
*/
it('includes file.nodeId as the string the Files sidebar provided', async () => {
const store = useFilesStore()
const nodeId = '9007199254740993'
const tempId = -1
store.files[tempId] = {
id: tempId,
nodeId,
name: 'copy of contract.pdf',
signers: [{ email: 'signer@example.com', identifyMethods: [{ method: 'email', value: 'signer@example.com', mandatory: 0 }] }],
signatureFlow: 'parallel',
}
store.selectedFileId = tempId

axiosMock.mockResolvedValue({
data: { ocs: { data: { id: 77, nodeId: 77, signatureFlow: 'parallel', signers: [] } } },
})

await store.saveOrUpdateSignatureRequest({})

const config = axiosMock.mock.calls[0][0]
expect(config.data.file).toEqual({ nodeId: '9007199254740993' })
})

it('serializes envelope files with string and number node ids as they are', async () => {
const store = useFilesStore()
store.selectedFileId = -1
store.files[-1] = {
id: -1,
name: 'Envelope',
files: [
{ id: -7, nodeId: '9007199254740993', name: 'first.pdf' },
{ id: -22, nodeId: 22, name: 'second.pdf' },
],
signers: [{ email: 'signer@example.com' }],
signatureFlow: 'parallel',
}
axiosMock.mockResolvedValue({
data: { ocs: { data: { id: 12, nodeId: 'real-node', signatureFlow: 'parallel', signers: [] } } },
})

await store.saveOrUpdateSignatureRequest({})

const config = axiosMock.mock.calls[0][0]
expect(config.data.files).toEqual([{ nodeId: '9007199254740993' }, { nodeId: 22 }])
})

it('does not send the empty node id tab.ts falls back to when the node has none', async () => {
const store = useFilesStore()
store.files[-1] = {
id: -1,
nodeId: '',
name: 'unknown.pdf',
signers: [{ email: 'signer@example.com' }],
signatureFlow: 'parallel',
}
store.selectedFileId = -1
axiosMock.mockResolvedValue({
data: { ocs: { data: { id: 12, nodeId: 12, signatureFlow: 'parallel', signers: [] } } },
})

await store.saveOrUpdateSignatureRequest({})

const config = axiosMock.mock.calls[0][0]
expect(config.data.file).toBeNull()
})

it('serializes envelope files with nodeId-based references for creation flows', async () => {
const store = useFilesStore()
store.selectedFileId = -1
Expand Down
26 changes: 26 additions & 0 deletions src/tests/tab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ describe('tab.ts', () => {
})
})

it('enabled() keeps the string node id of a PDF whose node has no numeric fileid', () => {
mockLoadState.mockReturnValue(true)
window.dispatchEvent(new Event('DOMContentLoaded'))
const tabConfig = mockRegisterSidebarTab.mock.calls[0][0] as {
enabled: (context: { node: Record<string, unknown> }) => boolean
}

const enabled = tabConfig.enabled({
node: {
id: '9007199254740993',
fileid: undefined,
basename: 'copy of contract.pdf',
dirname: '/Documents',
type: 'file',
mime: 'application/pdf',
},
})

expect(enabled).toBe(true)
expect(window.OCA.Libresign.fileInfo).toMatchObject({
id: '9007199254740993',
name: 'copy of contract.pdf',
path: '/Documents',
})
})

it('lazy mounts Vue only when custom element is connected and unmounts on disconnect', async () => {
window.dispatchEvent(new Event('DOMContentLoaded'))

Expand Down
Loading