mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: create folders and upload files on Stalwart 1.0 and tell its symlinks from folders
This commit is contained in:
@@ -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<typeof vi.fn>).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 };
|
||||
|
||||
@@ -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<string, Uint8Array>();
|
||||
vi.mock('../blob', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../blob')>();
|
||||
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<void> {
|
||||
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<FileNode[]> {
|
||||
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<string, unknown> | 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<string, unknown>; notCreated?: Record<string, { properties?: string[] }> }];
|
||||
expect(result.created?.l).toBeUndefined();
|
||||
expect(result.notCreated?.l?.properties).toEqual(['target']);
|
||||
}, 30_000);
|
||||
});
|
||||
+75
-24
@@ -40,6 +40,37 @@ export function isSymlink(node: Pick<FileNode, 'nodeType' | 'blobId'>): 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<string, FileNodeType> = { 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<T extends Partial<FileNode>>(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<string, unknown>): Record<string, unknown> {
|
||||
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<FileNode[]> {
|
||||
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<FileNode[]> {
|
||||
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<string, unknown>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): Promise<FileNode> {
|
||||
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<string, unknown> = { 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(
|
||||
|
||||
Reference in New Issue
Block a user