diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index ba3f6577..c526e0fc 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -674,6 +674,87 @@ describe('fileNodeKind', () => { }); }); +describe('Stalwart 1.0.0 (File Storage draft 18)', () => { + // The filenode account capability of 1.0.0 lists `supportedNodeTypes`; 0.16.27's doesn't. + const CAP_100 = { maxFileNodeDepth: 64, forbiddenNameChars: '/<>:"\\|?*', supportedNodeTypes: ['Directory', 'File', 'SymLink'] }; + const CAP_016 = { maxFileNodeDepth: null, forbiddenNameChars: '/<>:"\\|?*' }; + + function session(own: object, shared?: object) { + setSession({ + primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, + accounts: { + 'acc-1': { name: 'me@example.com', isPersonal: true, accountCapabilities: { [CAPABILITIES.FILES]: own } }, + ...(shared ? { 'acc-2': { name: 'Team', isPersonal: false, accountCapabilities: shared } } : {}), + }, + }); + } + + const creates = () => mockRequest.mock.calls.map(([calls]) => Object.values(calls[0][1].create ?? {})[0]); + + // Seen live: 1.0.0 answers a create without `@type` with "@type is required + // on creation." and one with `type` with "Invalid property.". + it('creates folders and files with @type and mediaType', async () => { + session(CAP_100); + mockUploadBlobOnce('blob-up', 'text/plain'); + mockRequest + .mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'd1' } } }, '0']] }) + .mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'f1', size: 3 } } }, '0']] }) + .mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'f2', size: 3 } } }, '0']] }); + + const dir = await createFolder('New', null); + const { uploaded } = await uploadFileBatch([{ uri: 'file:///b', name: 'b.txt', mimeType: 'text/plain' }], 'd1', []); + const copy = await copyFileNode(uploaded[0], null, 'b (1).txt'); + + expect(creates()).toEqual([ + { '@type': 'Directory', name: 'New' }, + { '@type': 'File', name: 'b.txt', mediaType: 'text/plain', blobId: 'blob-up', parentId: 'd1' }, + { '@type': 'File', name: 'b (1).txt', mediaType: 'text/plain', blobId: 'blob-up' }, + ]); + expect(dir).toMatchObject({ id: 'd1', name: 'New', nodeType: 'directory' }); + expect(copy).toMatchObject({ id: 'f2', type: 'text/plain', nodeType: 'file' }); + }); + + it('keeps the 0.16 shape on Stalwart 0.16, which refuses @type', async () => { + session(CAP_016); + mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'd1' }, 'new-file': { id: 'f1' } } }, '0']] }); + + await createFolder('New', null); + await copyFileNode({ id: 'f0', name: 'a.txt', type: 'text/plain', blobId: 'b1' }, null); + + expect(creates()).toEqual([ + { name: 'New' }, + { name: 'a.txt', type: 'text/plain', blobId: 'b1' }, + ]); + }); + + it('reads the kind from @type and the media type from mediaType', async () => { + session(CAP_100); + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/get', { + list: [ + { id: 'd1', name: 'Docs', parentId: null, '@type': 'Directory' }, + { id: 'l1', name: 'latest', parentId: 'd1', '@type': 'SymLink' }, + { id: 'f1', name: 'a.pdf', parentId: 'd1', '@type': 'File', mediaType: 'application/pdf', blobId: 'b1', size: 3 }, + ], + }, '0']], + }); + + const nodes = await getAllFileNodes(); + + expect(mockRequest.mock.calls[0][0][0][1].properties).toEqual(expect.arrayContaining(['@type', 'mediaType'])); + const byId = Object.fromEntries(nodes.map((n) => [n.id, n])); + expect(fileNodeKind(byId.d1)).toBe('directory'); + // Without @type the blob rule made this an empty folder. + expect(isSymlink(byId.l1)).toBe(true); + expect(isFolder(byId.l1)).toBe(false); + expect(byId.f1).toMatchObject({ type: 'application/pdf', nodeType: 'file' }); + }); +}); + +function mockUploadBlobOnce(blobId: string, type: string) { + (uploadBlob as ReturnType).mockResolvedValueOnce({ blobId, type, size: 3 }); +} + describe('"Shared with me" placeholders', () => { const RIGHTS_NONE = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false }; const RIGHTS_READ = { ...RIGHTS_NONE, mayRead: true }; diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts new file mode 100644 index 00000000..1c8eaef5 --- /dev/null +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -0,0 +1,182 @@ +/** + * Live check of the Files API against a real Stalwart server. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` + * and `BULWARK_LIVE_PASS` are set. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-phone-dark@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-files.test.ts + * + * Everything it makes lives in one scratch folder that is deleted afterwards. + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + +// The app uploads picked files from a device URI; here the "URI" names bytes +// held in memory, which go up through the same upload endpoint. +const pickedFiles = new Map(); +vi.mock('../blob', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uploadBlob: (uri: string, type: string) => { + const bytes = pickedFiles.get(uri); + if (!bytes) throw new Error(`no bytes for ${uri}`); + return actual.uploadBytes(bytes, type); + }, + }; +}); + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import type { FileNode } from '../types'; +import { + copyFileNode, + createFolder, + deleteFileNodes, + fileNodeKind, + filesAccountId, + getAllFileNodesAcrossAccounts, + getFileNodeDownloadUrl, + listFolder, + moveFileNode, + renameFileNode, + uploadFileBatch, +} from '../files'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const live = SERVER && PASS ? describe : describe.skip; + +let client: JMAPClient; + +async function connectSingleton(): Promise { + const mod = await import('../jmap-client'); + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); +} + +function pick(name: string, text: string) { + const uri = `mem://${name}-${pickedFiles.size}`; + pickedFiles.set(uri, new TextEncoder().encode(text)); + return { uri, name, mimeType: 'text/plain' }; +} + +async function nodes(): Promise { + return getAllFileNodesAcrossAccounts(); +} + +/** Stalwart 1.0 (File Storage draft 18) names the kind `@type`; it lists `supportedNodeTypes`. */ +function draft18(): boolean { + const cap = client.getAccountCapability(CAPABILITIES.FILES, filesAccountId()) as Record | undefined; + return !!cap && 'supportedNodeTypes' in cap; +} + +live('live Stalwart files', () => { + let scratch = ''; + + beforeAll(async () => { + await connectSingleton(); + scratch = (await createFolder(`live-files-${Date.now().toString(36)}`, null)).id; + }, 60_000); + + afterAll(async () => { + if (!scratch) return; + await deleteFileNodes([scratch]).catch(() => undefined); + // Asked by id, so a failed listing can't pass for a clean account. + const res = await client.request([['FileNode/get', { + accountId: filesAccountId(), ids: [scratch], properties: ['id'], + }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]); + expect(res.methodResponses[0][1].notFound).toEqual([scratch]); + }, 120_000); + + it('creates a folder, lists it as a folder and lists what it holds', async () => { + const sub = await createFolder('Sub folder', scratch); + const all = await nodes(); + const listed = all.find((n) => n.id === sub.id); + expect(listed?.name).toBe('Sub folder'); + expect(listed?.parentId).toBe(scratch); + expect(fileNodeKind(listed!)).toBe('directory'); + expect(listFolder(all, scratch).map((n) => n.id)).toContain(sub.id); + }, 60_000); + + it('uploads files, lists them as files with their media type and downloads the bytes', async () => { + const { uploaded, failed } = await uploadFileBatch( + [pick('notes.txt', 'hello from the live test'), pick('con.txt', 'reserved stem')], + scratch, + [], + ); + expect(failed).toEqual([]); + expect(uploaded).toHaveLength(2); + + const all = await nodes(); + const notes = all.find((n) => n.id === uploaded[0].id)!; + expect(notes.name).toBe('notes.txt'); + expect(fileNodeKind(notes)).toBe('file'); + expect(notes.type).toBe('text/plain'); + expect(notes.size).toBe(24); + + const res = await fetch(getFileNodeDownloadUrl(notes), { headers: { Authorization: client.authHeader } }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('hello from the live test'); + }, 60_000); + + it('renames, moves, copies and deletes', async () => { + const box = await createFolder('Box', scratch); + const { uploaded } = await uploadFileBatch([pick('a.txt', 'abc')], scratch, []); + const file = uploaded[0]; + + await renameFileNode(file.id, 'b.txt'); + await moveFileNode(file.id, box.id); + let all = await nodes(); + expect(all.find((n) => n.id === file.id)).toMatchObject({ name: 'b.txt', parentId: box.id }); + + // A folder moves too, and back. + const inner = await createFolder('Inner', scratch); + await moveFileNode(inner.id, box.id); + await renameFileNode(inner.id, 'Inner renamed'); + all = await nodes(); + expect(all.find((n) => n.id === inner.id)).toMatchObject({ name: 'Inner renamed', parentId: box.id }); + + const listed = all.find((n) => n.id === file.id)!; + const copy = await copyFileNode(listed, scratch, 'b (copy).txt'); + all = await nodes(); + const copied = all.find((n) => n.id === copy.id)!; + expect(copied).toMatchObject({ name: 'b (copy).txt', parentId: scratch, size: 3, type: 'text/plain' }); + expect(fileNodeKind(copied)).toBe('file'); + + await deleteFileNodes([copy.id, box.id]); + all = await nodes(); + // The folder's children went with it. + expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]); + }, 90_000); + + it('moves a node to the drive root and back', async () => { + const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []); + const id = uploaded[0].id; + try { + await moveFileNode(id, null); + expect((await nodes()).find((n) => n.id === id)?.parentId ?? null).toBeNull(); + } finally { + await moveFileNode(id, scratch); + } + expect((await nodes()).find((n) => n.id === id)?.parentId).toBe(scratch); + }, 60_000); + + it('cannot create a symbolic link over JMAP on Stalwart 1.0', async () => { + if (!draft18()) return; + // Stalwart 1.0 lists SymLink among its node types but refuses every + // `target`, so the app offers no way to make one. + const res = await client.request([['FileNode/set', { + accountId: filesAccountId(), + create: { l: { '@type': 'SymLink', name: 'link', parentId: scratch, target: 'Sub folder' } }, + }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]); + const [, result] = res.methodResponses[0] as unknown as [string, { created?: Record; notCreated?: Record }]; + expect(result.created?.l).toBeUndefined(); + expect(result.notCreated?.l?.properties).toEqual(['target']); + }, 30_000); +}); diff --git a/src/api/files.ts b/src/api/files.ts index a96b6759..b17a9359 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -40,6 +40,37 @@ export function isSymlink(node: Pick): boolean return fileNodeKind(node) === 'symlink'; } +/** + * Stalwart 1.0.0 implements File Storage draft 18, which names the kind + * `@type` ("File", "Directory", "SymLink") and the media type `mediaType`. + * 0.16 calls them `nodeType` and `type`. Each server leaves the other's names + * out of FileNode/get and refuses them in a create ("@type is required on + * creation." on 1.0.0, "Invalid property." for `@type` on 0.16.27). + */ +const DRAFT_NODE_TYPES: Record = { File: 'file', Directory: 'directory', SymLink: 'symlink' }; + +type DraftFileNode = { '@type'?: unknown; mediaType?: unknown }; + +/** A node of either draft, with the kind in `nodeType` and the media type in `type`. */ +export function fromDraftFileNode>(node: T & DraftFileNode): T { + const kind = typeof node['@type'] === 'string' ? DRAFT_NODE_TYPES[node['@type']] : undefined; + const mediaType = typeof node.mediaType === 'string' ? node.mediaType : undefined; + if (!kind && mediaType === undefined) return node; + return { + ...node, + ...(kind && !node.nodeType ? { nodeType: kind } : {}), + ...(mediaType !== undefined && node.type == null ? { type: mediaType } : {}), + }; +} + +/** The properties of a draft-18 FileNode/set create, from 0.16 ones (no blob: a folder). */ +function toDraftFileNodeCreate(props: Record): Record { + const { type, ...rest } = props; + return rest.blobId == null + ? { '@type': 'Directory', ...rest } + : { '@type': 'File', ...rest, mediaType: type }; +} + // Stalwart omits shareWith/myRights from FileNode/get unless they are // requested explicitly, so the share sheet and indicators must name them here. // The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode); @@ -50,6 +81,8 @@ const FILE_NODE_PROPERTIES = [ // Tells 1.0's symlinks from folders (see fileNodeKind); servers before // 0.16.6 skip it. 'nodeType', + // `nodeType` and `type` as Stalwart 1.0.0 names them (see fromDraftFileNode). + '@type', 'mediaType', 'shareWith', 'myRights', ]; @@ -162,7 +195,7 @@ async function fetchAllFileNodes(accountId: string): Promise { if (!result || result[0] === 'error') { throw new Error(result?.[1]?.description || 'FileNode list failed'); } - const firstPage = (result[1].list ?? []) as FileNode[]; + const firstPage = ((result[1].list ?? []) as FileNode[]).map(fromDraftFileNode); const maxObjects = jmapClient.getMaxObjectsInGet(); if (firstPage.length < maxObjects) return firstPage; @@ -178,7 +211,7 @@ async function fetchAllFileNodes(accountId: string): Promise { if (r[0] !== 'FileNode/get') { throw new Error(r[1]?.description || 'FileNode/get failed'); } - for (const node of (r[1].list ?? []) as FileNode[]) known.set(node.id, node); + for (const node of (r[1].list ?? []) as FileNode[]) known.set(node.id, fromDraftFileNode(node)); } } }; @@ -325,6 +358,44 @@ export function listFolder(nodes: FileNode[], parentId: string | null): FileNode return nodes.filter((n) => !n.discoverableOnly && (n.parentId ?? null) === parentId); } +/** + * Stalwart 1.0.0 (File Storage draft 18) wants `@type` and `mediaType` in a + * create (see toDraftFileNodeCreate). Of the drafts Stalwart has shipped, + * only that one lists `supportedNodeTypes`. A shared account may come + * without the capability; the user's own files account stands in, as + * Stalwart publishes the same one for every account. + */ +function usesDraftFileNodeCreate(accountId: string): boolean { + // Read the accounts' own capabilities: the session-wide one is empty on + // both versions. + const accounts = jmapClient.currentSession?.accounts; + const cap = accounts?.[accountId]?.accountCapabilities?.[CAPABILITIES.FILES] + ?? accounts?.[filesAccountId()]?.accountCapabilities?.[CAPABILITIES.FILES]; + return !!cap && typeof cap === 'object' && 'supportedNodeTypes' in cap; +} + +/** One FileNode/set create in `accountId`, in the shape that server takes. */ +async function createFileNodeIn( + accountId: string, + props: Record, + key: string, + fallback: string, +): Promise { + const wire = usesDraftFileNodeCreate(accountId) ? toDraftFileNodeCreate(props) : props; + const res = await jmapClient.request( + [['FileNode/set', { accountId, create: { [key]: wire } }, '0']], + fileUsing(), + ); + const result = requireMethodResult(res, '0', 'FileNode/set'); + const created = result.created?.[key]; + if (!created) { + throw fileSetError(result.notCreated?.[key], fallback); + } + // The create response carries only server-set properties; keep the ones + // sent so callers can rely on name, parentId and type. + return fromDraftFileNode({ ...wire, ...created } as FileNode); +} + export async function createFolder( name: string, parentId: string | null, @@ -332,17 +403,7 @@ export async function createFolder( const accountId = filesAccountId(); const props: Record = { name: acceptedFileName(name, getFileNameRules()) }; if (parentId !== null) props.parentId = parentId; - - const res = await jmapClient.request( - [['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']], - fileUsing(), - ); - const result = requireMethodResult(res, '0', 'FileNode/set'); - const created = result.created?.['new-dir']; - if (!created) { - throw fileSetError(result.notCreated?.['new-dir'], 'Create folder failed'); - } - return { ...props, ...created } as FileNode; + return createFileNodeIn(accountId, props, 'new-dir', 'Create folder failed'); } export async function updateFileNode( @@ -421,17 +482,7 @@ async function createFileNodeFromBlob( blobId, }; if (parentId !== null) props.parentId = parentId; - - const res = await jmapClient.request( - [['FileNode/set', { accountId, create: { 'new-file': props } }, '0']], - fileUsing(), - ); - const result = requireMethodResult(res, '0', 'FileNode/set'); - const created = result.created?.['new-file']; - if (!created) { - throw fileSetError(result.notCreated?.['new-file'], 'Upload failed'); - } - return { ...props, ...created } as FileNode; + return createFileNodeIn(accountId, props, 'new-file', 'Upload failed'); } export async function uploadFileNode(