diff --git a/.eslintignore b/.eslintignore index ba1e498bbe..9c222df32f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1774,6 +1774,7 @@ packages/tools/fuzzer/Client.js packages/tools/fuzzer/ClientPool.js packages/tools/fuzzer/Server.js packages/tools/fuzzer/constants.js +packages/tools/fuzzer/model/FolderRecord.js packages/tools/fuzzer/sync-fuzzer.js packages/tools/fuzzer/types.js packages/tools/fuzzer/utils/SeededRandom.js diff --git a/.gitignore b/.gitignore index d8c5269131..5fda8fab81 100644 --- a/.gitignore +++ b/.gitignore @@ -1747,6 +1747,7 @@ packages/tools/fuzzer/Client.js packages/tools/fuzzer/ClientPool.js packages/tools/fuzzer/Server.js packages/tools/fuzzer/constants.js +packages/tools/fuzzer/model/FolderRecord.js packages/tools/fuzzer/sync-fuzzer.js packages/tools/fuzzer/types.js packages/tools/fuzzer/utils/SeededRandom.js diff --git a/packages/app-cli/app/command-share.ts b/packages/app-cli/app/command-share.ts index ef2509ceb0..ccc30b69f5 100644 --- a/packages/app-cli/app/command-share.ts +++ b/packages/app-cli/app/command-share.ts @@ -149,6 +149,7 @@ class Command extends BaseCommand { waiting: invitation.status === ShareUserStatus.Waiting, rejected: invitation.status === ShareUserStatus.Rejected, folderId: invitation.share.folder_id, + canWrite: !!invitation.can_write, fromUser: { email: invitation.share.user?.email, }, diff --git a/packages/tools/fuzzer/ActionTracker.ts b/packages/tools/fuzzer/ActionTracker.ts index 4719e23b9c..238ce2f37f 100644 --- a/packages/tools/fuzzer/ActionTracker.ts +++ b/packages/tools/fuzzer/ActionTracker.ts @@ -1,6 +1,7 @@ import { strict as assert } from 'assert'; -import { ActionableClient, FolderData, FolderMetadata, FuzzContext, ItemId, NoteData, TreeItem, assertIsFolder, isFolder } from './types'; +import { ActionableClient, FolderData, FuzzContext, ItemId, NoteData, ShareOptions, TreeItem, assertIsFolder, isFolder } from './types'; import type Client from './Client'; +import FolderRecord from './model/FolderRecord'; interface ClientData { childIds: ItemId[]; @@ -11,6 +12,26 @@ class ActionTracker { private tree_: Map = new Map(); public constructor(private readonly context_: FuzzContext) {} + private getToplevelParent_(item: ItemId|TreeItem) { + let itemId = typeof item === 'string' ? item : item.id; + const originalItemId = itemId; + const seenIds = new Set(); + while (this.idToItem_.get(itemId)?.parentId) { + seenIds.add(itemId); + + itemId = this.idToItem_.get(itemId).parentId; + if (seenIds.has(itemId)) { + throw new Error('Assertion failure: Item hierarchy is not a tree.'); + } + } + + const toplevelItem = this.idToItem_.get(itemId); + assert.ok(toplevelItem, `Parent not found for item, top:${itemId} (started at ${originalItemId})`); + assert.equal(toplevelItem.parentId, '', 'Should be a toplevel item'); + + return toplevelItem; + } + private checkRep_() { const checkItem = (itemId: ItemId) => { assert.match(itemId, /^[a-zA-Z0-9]{32}$/, 'item IDs should be 32 character alphanumeric strings'); @@ -33,13 +54,16 @@ class ActionTracker { // Shared folders assert.ok(item.ownedByEmail, 'all folders should have a "shareOwner" property (even if not shared)'); - assert.ok(!item.sharedWith.includes(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list'); - if (item.sharedWith.length > 0) { + if (item.isRootSharedItem) { assert.equal(item.parentId, '', 'only toplevel folders should be shared'); } - for (const sharedWith of item.sharedWith) { + for (const sharedWith of item.shareRecipients) { assert.ok(this.tree_.has(sharedWith), 'all sharee users should exist'); } + // isSharedWith is only valid for toplevel folders + if (item.parentId === '') { + assert.ok(!item.isSharedWith(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list'); + } // Uniqueness assert.equal( @@ -82,10 +106,7 @@ class ActionTracker { if (!parent) throw new Error(`Parent with ID ${parentId} not found.`); if (!isFolder(parent)) throw new Error(`Item ${parentId} is not a folder`); - this.idToItem_.set(parentId, { - ...parent, - childIds: updateFn(parent.childIds), - }); + this.idToItem_.set(parentId, parent.withChildren(updateFn(parent.childIds))); }; const addRootItem = (itemId: ItemId) => { const clientData = this.tree_.get(clientId); @@ -220,14 +241,40 @@ class ActionTracker { return result; }; - const listFoldersDetailed = () => { - return mapItems((item): FolderData => { + const getAllFolders = () => { + return mapItems((item): FolderRecord => { return isFolder(item) ? item : null; }).filter(item => !!item); }; + const isReadOnly = (item: ItemId|TreeItem) => { + if (item === '') return false; + + const toplevelItem = this.getToplevelParent_(item); + assertIsFolder(toplevelItem); + return toplevelItem.isReadOnlySharedWith(clientId); + }; + + const isShared = (item: TreeItem) => { + const toplevelItem = this.getToplevelParent_(item); + assertIsFolder(toplevelItem); + return toplevelItem.isRootSharedItem; + }; + + const assertWriteable = (item: ItemId|TreeItem) => { + if (typeof item !== 'string') { + item = item.id; + } + + if (isReadOnly(item)) { + throw new Error(`Item is read-only: ${item}`); + } + }; + const tracker: ActionableClient = { createNote: (data: NoteData) => { + assertWriteable(data.parentId); + assert.ok(!!data.parentId, `note ${data.id} should have a parentId`); assert.ok(!this.idToItem_.has(data.id), `note ${data.id} should not yet exist`); this.idToItem_.set(data.id, { @@ -239,6 +286,8 @@ class ActionTracker { return Promise.resolve(); }, updateNote: (data: NoteData) => { + assertWriteable(data.parentId); + const oldItem = this.idToItem_.get(data.id); assert.ok(oldItem, `note ${data.id} should exist`); assert.ok(!!data.parentId, `note ${data.id} should have a parentId`); @@ -252,14 +301,17 @@ class ActionTracker { this.checkRep_(); return Promise.resolve(); }, - createFolder: (data: FolderMetadata) => { - this.idToItem_.set(data.id, { + createFolder: (data: FolderData) => { + const parentId = data.parentId ?? ''; + assertWriteable(parentId); + + this.idToItem_.set(data.id, new FolderRecord({ ...data, - parentId: data.parentId ?? '', + parentId: parentId ?? '', childIds: getChildIds(data.id), sharedWith: [], ownedByEmail: clientId, - }); + })); addChild(data.parentId, data.id); this.checkRep_(); @@ -270,18 +322,19 @@ class ActionTracker { const item = this.idToItem_.get(id); if (!item) throw new Error(`Not found ${id}`); - if (!isFolder(item)) throw new Error(`Not a folder ${id}`); + assertIsFolder(item); + assertWriteable(item); removeItemRecursive(id); this.checkRep_(); return Promise.resolve(); }, - shareFolder: (id: ItemId, shareWith: Client) => { + shareFolder: (id: ItemId, shareWith: Client, options: ShareOptions) => { const itemToShare = this.idToItem_.get(id); assertIsFolder(itemToShare); - const alreadyShared = itemToShare.sharedWith.includes(shareWith.email); + const alreadyShared = itemToShare.isSharedWith(shareWith.email); assert.ok(!alreadyShared, `Folder ${id} should not yet be shared with ${shareWith.email}`); const shareWithChildIds = this.tree_.get(shareWith.email).childIds; @@ -294,10 +347,9 @@ class ActionTracker { childIds: [...shareWithChildIds, id], }); - this.idToItem_.set(id, { - ...itemToShare, - sharedWith: [...itemToShare.sharedWith, shareWith.email], - }); + this.idToItem_.set( + id, itemToShare.withShared(shareWith.email, options.readOnly), + ); this.checkRep_(); return Promise.resolve(); @@ -306,7 +358,7 @@ class ActionTracker { const targetItem = this.idToItem_.get(id); assertIsFolder(targetItem); - assert.ok(targetItem.sharedWith.includes(shareWith.email), `Folder ${id} should be shared with ${shareWith.label}`); + assert.ok(targetItem.isSharedWith(shareWith.email), `Folder ${id} should be shared with ${shareWith.label}`); const otherSubTree = this.tree_.get(shareWith.email); this.tree_.set(shareWith.email, { @@ -314,35 +366,39 @@ class ActionTracker { childIds: otherSubTree.childIds.filter(childId => childId !== id), }); - this.idToItem_.set(id, { - ...targetItem, - sharedWith: targetItem.sharedWith.filter(shareeEmail => shareeEmail !== shareWith.email), - }); + this.idToItem_.set(id, targetItem.withUnshared(shareWith.email)); this.checkRep_(); return Promise.resolve(); }, moveItem: (itemId, newParentId) => { const item = this.idToItem_.get(itemId); - assert.ok(item, `item with ${itemId} should exist`); - if (newParentId) { - const parent = this.idToItem_.get(newParentId); - assert.ok(parent, `parent with ID ${newParentId} should exist`); - } else { - assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder'); - } + const validateParameters = () => { + assert.ok(item, `item with ${itemId} should exist`); - if (isFolder(item)) { - assert.deepEqual(item.sharedWith, [], 'cannot move toplevel shared folders without first unsharing'); - } + if (newParentId) { + const parent = this.idToItem_.get(newParentId); + assert.ok(parent, `parent with ID ${newParentId} should exist`); + } else { + assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder'); + } + + if (isFolder(item)) { + assert.equal(item.isRootSharedItem, false, 'cannot move toplevel shared folders without first unsharing'); + } + + assertWriteable(itemId); + assertWriteable(newParentId); + }; + validateParameters(); removeChild(item.parentId, itemId); addChild(newParentId, itemId); - this.idToItem_.set(itemId, { - ...item, - parentId: newParentId, - }); + this.idToItem_.set( + itemId, + isFolder(item) ? item.withParent(newParentId) : { ...item, parentId: newParentId }, + ); this.checkRep_(); return Promise.resolve(); @@ -351,17 +407,21 @@ class ActionTracker { listNotes: () => { const notes = mapItems(item => { return isFolder(item) ? null : item; - }).filter(item => !!item); + }).filter(item => !!item).map(item => ({ + ...item, + isShared: isShared(item), + })); this.checkRep_(); return Promise.resolve(notes); }, listFolders: () => { this.checkRep_(); - const folderData = listFoldersDetailed().map(item => ({ + const folderData = getAllFolders().map(item => ({ id: item.id, title: item.title, parentId: item.parentId, + isShared: isShared(item), })); return Promise.resolve(folderData); @@ -390,10 +450,13 @@ class ActionTracker { return Promise.resolve(descendants); }, randomFolder: async (options) => { - let folders = listFoldersDetailed(); + let folders = getAllFolders(); if (options.filter) { folders = folders.filter(options.filter); } + if (!options.includeReadOnly) { + folders = folders.filter(folder => !isReadOnly(folder.id)); + } const folderIndex = this.context_.randInt(0, folders.length); return folders.length ? folders[folderIndex] : null; diff --git a/packages/tools/fuzzer/Client.ts b/packages/tools/fuzzer/Client.ts index b0acd46e8b..e01285795a 100644 --- a/packages/tools/fuzzer/Client.ts +++ b/packages/tools/fuzzer/Client.ts @@ -1,5 +1,5 @@ import uuid, { createSecureRandom } from '@joplin/lib/uuid'; -import { ActionableClient, FolderMetadata, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions } from './types'; +import { ActionableClient, FolderData, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, RandomNoteOptions, ShareOptions } from './types'; import { join } from 'path'; import { mkdir, remove } from 'fs-extra'; import getStringProperty from './utils/getStringProperty'; @@ -128,10 +128,11 @@ class Client implements ActionableClient { account.onClientConnected(); // Joplin Server sync - await client.execCliCommand_('config', 'sync.target', '9'); - await client.execCliCommand_('config', 'sync.9.path', context.serverUrl); - await client.execCliCommand_('config', 'sync.9.username', account.email); - await client.execCliCommand_('config', 'sync.9.password', account.password); + const targetId = context.isJoplinCloud ? '10' : '9'; + await client.execCliCommand_('config', 'sync.target', targetId); + await client.execCliCommand_('config', `sync.${targetId}.path`, context.serverUrl); + await client.execCliCommand_('config', `sync.${targetId}.username`, account.email); + await client.execCliCommand_('config', `sync.${targetId}.password`, account.password); await client.execCliCommand_('config', 'api.token', apiData.token); await client.execCliCommand_('config', 'api.port', String(apiData.port)); @@ -448,7 +449,7 @@ class Client implements ActionableClient { }); } - public async createFolder(folder: FolderMetadata) { + public async createFolder(folder: FolderData) { logger.info('Create folder', folder.id, 'in', `${folder.parentId ?? 'root'}/${this.label}`); await this.tracker_.createFolder(folder); @@ -510,8 +511,8 @@ class Client implements ActionableClient { await this.execCliCommand_('rmbook', '--permanent', '--force', id); } - public async shareFolder(id: string, shareWith: Client) { - await this.tracker_.shareFolder(id, shareWith); + public async shareFolder(id: string, shareWith: Client, options: ShareOptions) { + await this.tracker_.shareFolder(id, shareWith, options); const getPendingInvitations = async (target: Client) => { const shareWithIncoming = JSON.parse((await target.execCliCommand_('share', 'list', '--json')).stdout); @@ -524,8 +525,11 @@ class Client implements ActionableClient { }; await retryWithCount(async () => { - logger.info('Share', id, 'with', shareWith.label); - await this.execCliCommand_('share', 'add', id, shareWith.email); + logger.info('Share', id, 'with', shareWith.label, options.readOnly ? '(read-only)' : ''); + const readOnlyArgs = options.readOnly ? ['--read-only'] : []; + await this.execCliCommand_( + 'share', 'add', ...readOnlyArgs, id, shareWith.email, + ); await this.sync(); await shareWith.sync(); @@ -536,6 +540,7 @@ class Client implements ActionableClient { accepted: false, waiting: true, rejected: false, + canWrite: !options.readOnly, folderId: id, fromUser: { email: this.email, @@ -571,7 +576,7 @@ class Client implements ActionableClient { public async listNotes() { const params = { - fields: 'id,parent_id,body,title,is_conflict,conflict_original_id', + fields: 'id,parent_id,body,title,is_conflict,conflict_original_id,share_id', include_deleted: '1', include_conflicts: '1', }; @@ -586,13 +591,14 @@ class Client implements ActionableClient { ) : getStringProperty(item, 'parent_id'), title: getStringProperty(item, 'title'), body: getStringProperty(item, 'body'), + isShared: getStringProperty(item, 'share_id') !== '', }), ); } public async listFolders() { const params = { - fields: 'id,parent_id,title', + fields: 'id,parent_id,title,share_id', include_deleted: '1', }; return await this.execPagedApiCommand_( @@ -603,6 +609,7 @@ class Client implements ActionableClient { id: getStringProperty(item, 'id'), parentId: getStringProperty(item, 'parent_id'), title: getStringProperty(item, 'title'), + isShared: getStringProperty(item, 'share_id') !== '', }), ); } @@ -615,8 +622,8 @@ class Client implements ActionableClient { return this.tracker_.allFolderDescendants(parentId); } - public async randomNote() { - return this.tracker_.randomNote(); + public async randomNote(options: RandomNoteOptions) { + return this.tracker_.randomNote(options); } public async checkState() { diff --git a/packages/tools/fuzzer/Server.ts b/packages/tools/fuzzer/Server.ts index a69bbbba19..85088f31ad 100644 --- a/packages/tools/fuzzer/Server.ts +++ b/packages/tools/fuzzer/Server.ts @@ -1,6 +1,5 @@ -import { join } from 'path'; +import { join, resolve } from 'path'; import { HttpMethod, Json, UserData } from './types'; -import { packagesDir } from './constants'; import JoplinServerApi from '@joplin/lib/JoplinServerApi'; import { Env } from '@joplin/lib/models/Setting'; import execa = require('execa'); @@ -27,16 +26,17 @@ export default class Server { private server_: execa.ExecaChildProcess; public constructor( + serverBaseDirectory: string, private readonly serverUrl_: string, private readonly adminAuth_: UserData, ) { - const serverDir = join(packagesDir, 'server'); + const serverDir = resolve(serverBaseDirectory); const mainEntrypoint = join(serverDir, 'dist', 'app.js'); this.server_ = execa.node(mainEntrypoint, [ '--env', 'dev', ], { env: { JOPLIN_IS_TESTING: '1' }, - cwd: join(packagesDir, 'server'), + cwd: serverDir, stdin: 'ignore', // No stdin // For debugging: // stderr: process.stderr, diff --git a/packages/tools/fuzzer/model/FolderRecord.ts b/packages/tools/fuzzer/model/FolderRecord.ts new file mode 100644 index 0000000000..2a5fd297d5 --- /dev/null +++ b/packages/tools/fuzzer/model/FolderRecord.ts @@ -0,0 +1,144 @@ +import { strict as assert } from 'node:assert'; +import type { FolderData, ItemId } from '../types'; + +export type ShareRecord = { + email: string; + readOnly: boolean; +}; + +interface InitializationOptions extends FolderData { + childIds: ItemId[]; + sharedWith: ShareRecord[]; + // Email of the Joplin Server account that controls the item + ownedByEmail: string; +} + +const validateId = (id: string) => { + return !!id.match(/^[a-zA-Z0-9]{32}$/); +}; + +export default class FolderRecord implements FolderData { + public readonly parentId: string; + public readonly id: string; + public readonly title: string; + public readonly ownedByEmail: string; + public readonly childIds: ItemId[]; + private readonly sharedWith_: ShareRecord[]; + + public constructor(options: InitializationOptions) { + this.parentId = options.parentId; + this.id = options.id; + this.title = options.title; + this.childIds = options.childIds; + this.ownedByEmail = options.ownedByEmail; + this.sharedWith_ = options.sharedWith; + + if (this.parentId !== '' && !validateId(this.parentId)) { + throw new Error(`Invalid parent ID: ${this.parentId}`); + } + + if (!validateId(this.id)) { + throw new Error(`Invalid ID: ${this.id}`); + } + } + + public get shareRecipients() { + return this.sharedWith_.map(sharee => sharee.email); + } + + private get metadata_(): InitializationOptions { + return { + parentId: this.parentId, + id: this.id, + title: this.title, + ownedByEmail: this.ownedByEmail, + childIds: [...this.childIds], + sharedWith: [...this.sharedWith_], + }; + } + + public get isRootSharedItem() { + return this.sharedWith_.length > 0; + } + + public isSharedWith(email: string) { + assert.equal(this.parentId, '', 'only supported for toplevel folders'); + return this.sharedWith_.some(record => record.email === email); + } + + public isReadOnlySharedWith(email: string) { + assert.equal(this.parentId, '', 'only supported for toplevel folders'); + return this.sharedWith_.some(record => record.email === email && record.readOnly); + } + + public withTitle(title: string) { + return new FolderRecord({ + ...this.metadata_, + title, + }); + } + + public withParent(parentId: ItemId) { + return new FolderRecord({ + ...this.metadata_, + parentId, + }); + } + + public withId(id: ItemId) { + return new FolderRecord({ + ...this.metadata_, + id, + }); + } + + public withChildren(childIds: ItemId[]) { + return new FolderRecord({ + ...this.metadata_, + childIds: [...childIds], + }); + } + + public withChildAdded(childId: ItemId) { + if (this.childIds.includes(childId)) { + return this; + } + + return this.withChildren([...this.childIds, childId]); + } + + public withChildRemoved(childId: ItemId) { + return this.withChildren( + this.childIds.filter(id => id !== childId), + ); + } + + public withShared(recipientEmail: string, readOnly: boolean) { + if (this.isSharedWith(recipientEmail) && this.isReadOnlySharedWith(recipientEmail) === readOnly) { + return this; + } + + if (this.parentId !== '') { + throw new Error('Cannot share non-top-level folder'); + } + + return new FolderRecord({ + ...this.metadata_, + sharedWith: [ + ...this.sharedWith_.filter(record => record.email !== recipientEmail), + { email: recipientEmail, readOnly }, + ], + }); + } + + public withUnshared(recipientEmail: string) { + if (!this.isSharedWith(recipientEmail)) { + return this; + } + + return new FolderRecord({ + ...this.metadata_, + sharedWith: this.sharedWith_.filter(record => record.email !== recipientEmail), + }); + } +} diff --git a/packages/tools/fuzzer/sync-fuzzer.ts b/packages/tools/fuzzer/sync-fuzzer.ts index 5335da5000..4fabba4930 100644 --- a/packages/tools/fuzzer/sync-fuzzer.ts +++ b/packages/tools/fuzzer/sync-fuzzer.ts @@ -14,6 +14,7 @@ import yargs = require('yargs'); import { strict as assert } from 'assert'; import openDebugSession from './utils/openDebugSession'; import { Second } from '@joplin/utils/time'; +import { packagesDir } from './constants'; const { shimInit } = require('@joplin/lib/shim-init-node'); const globalLogger = new Logger(); @@ -42,7 +43,7 @@ const createProfilesDirectory = async () => { const doRandomAction = async (context: FuzzContext, client: Client, clientPool: ClientPool) => { const selectOrCreateParentFolder = async () => { - let parentId = (await client.randomFolder({}))?.id; + let parentId = (await client.randomFolder({ includeReadOnly: false }))?.id; // Create a toplevel folder to serve as this // folder's parent if none exist yet @@ -58,8 +59,9 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: return parentId; }; - const selectOrCreateNote = async () => { - let note = await client.randomNote(); + const selectOrCreateWriteableNote = async () => { + const options = { includeReadOnly: false }; + let note = await client.randomNote(options); if (!note) { await client.createNote({ @@ -69,7 +71,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: body: 'Body', }); - note = await client.randomNote(); + note = await client.randomNote(options); assert.ok(note, 'should have selected a random note'); } @@ -111,7 +113,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: return true; }, renameNote: async () => { - const note = await selectOrCreateNote(); + const note = await selectOrCreateWriteableNote(); await client.updateNote({ ...note, @@ -121,7 +123,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: return true; }, updateNoteBody: async () => { - const note = await selectOrCreateNote(); + const note = await selectOrCreateWriteableNote(); await client.updateNote({ ...note, @@ -131,10 +133,10 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: return true; }, moveNote: async () => { - const note = await client.randomNote(); - if (!note) return false; + const note = await selectOrCreateWriteableNote(); const targetParent = await client.randomFolder({ filter: folder => folder.id !== note.parentId, + includeReadOnly: false, }); if (!targetParent) return false; @@ -150,32 +152,35 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: filter: candidate => { const isToplevel = !candidate.parentId; const ownedByCurrent = candidate.ownedByEmail === client.email; - const alreadyShared = candidate.sharedWith.includes(other.email); + const alreadyShared = isToplevel && candidate.isSharedWith(other.email); return isToplevel && ownedByCurrent && !alreadyShared; }, + includeReadOnly: true, }); if (!target) return false; - await client.shareFolder(target.id, other); + const readOnly = context.randInt(0, 2) === 1 && context.isJoplinCloud; + await client.shareFolder(target.id, other, { readOnly }); return true; }, unshareFolder: async () => { const target = await client.randomFolder({ filter: candidate => { - return candidate.sharedWith.length > 0 && candidate.ownedByEmail === client.email; + return candidate.isRootSharedItem && candidate.ownedByEmail === client.email; }, + includeReadOnly: true, }); if (!target) return false; - const recipientIndex = context.randInt(0, target.sharedWith.length); - const recipientEmail = target.sharedWith[recipientIndex]; + const recipientIndex = context.randInt(0, target.shareRecipients.length); + const recipientEmail = target.shareRecipients[recipientIndex]; const recipient = clientPool.clientsByEmail(recipientEmail)[0]; assert.ok(recipient, `invalid state -- recipient ${recipientEmail} should exist`); await client.removeFromShare(target.id, recipient); return true; }, deleteFolder: async () => { - const target = await client.randomFolder({}); + const target = await client.randomFolder({ includeReadOnly: false }); if (!target) return false; await client.deleteFolder(target.id); @@ -185,6 +190,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: const target = await client.randomFolder({ // Don't choose items that are already toplevel filter: item => !!item.parentId, + includeReadOnly: false, }); if (!target) return false; @@ -194,7 +200,8 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: moveFolderTo: async () => { const target = await client.randomFolder({ // Don't move shared folders (should not be allowed by the GUI in the main apps). - filter: item => item.sharedWith.length === 0, + filter: item => !item.isRootSharedItem, + includeReadOnly: false, }); if (!target) return false; @@ -205,6 +212,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool: // Avoid making the folder a child of itself return !targetDescendants.has(item.id); }, + includeReadOnly: false, }); if (!newParent) return false; @@ -288,6 +296,9 @@ interface Options { maximumSteps: number; maximumStepsBetweenSyncs: number; clientCount: number; + + serverPath: string; + isJoplinCloud: boolean; } const main = async (options: Options) => { @@ -319,7 +330,7 @@ const main = async (options: Options) => { try { const joplinServerUrl = 'http://localhost:22300/'; - const server = new Server(joplinServerUrl, { + const server = new Server(options.serverPath, joplinServerUrl, { email: 'admin@localhost', password: env['FUZZER_SERVER_ADMIN_PASSWORD'] ?? 'admin', }); @@ -335,8 +346,13 @@ const main = async (options: Options) => { logger.info('Starting with seed', options.seed); const random = new SeededRandom(options.seed); + if (options.isJoplinCloud) { + logger.info('Sync target: Joplin Cloud'); + } + const fuzzContext: FuzzContext = { serverUrl: joplinServerUrl, + isJoplinCloud: options.isJoplinCloud, baseDir: profilesDirectory.path, execApi: server.execApi.bind(server), randInt: (a, b) => random.nextInRange(a, b), @@ -425,13 +441,24 @@ void yargs default: 3, defaultDescription: 'Number of client apps to create.', }, + 'joplin-cloud': { + type: 'string', + default: '', + defaultDescription: [ + 'A path: If provided, this should be an absolute path to a Joplin Cloud repository. ', + 'This also enables testing for some Joplin Cloud-specific features (e.g. read-only shares).', + ].join(''), + }, }); }, async (argv) => { + const serverPath = argv.joplinCloud ? argv.joplinCloud : join(packagesDir, 'server'); await main({ seed: argv.seed, maximumSteps: argv.steps, clientCount: argv.clients, + serverPath: serverPath, + isJoplinCloud: !!argv.joplinCloud, maximumStepsBetweenSyncs: argv['steps-between-syncs'], }); }, diff --git a/packages/tools/fuzzer/types.ts b/packages/tools/fuzzer/types.ts index ae9f874c2c..906ac73e07 100644 --- a/packages/tools/fuzzer/types.ts +++ b/packages/tools/fuzzer/types.ts @@ -1,36 +1,38 @@ import type Client from './Client'; +import type FolderRecord from './model/FolderRecord'; export type Json = string|number|Json[]|{ [key: string]: Json }; export type HttpMethod = 'GET'|'POST'|'DELETE'|'PUT'|'PATCH'; export type ItemId = string; -export type NoteData = { +export interface NoteData { parentId: ItemId; id: ItemId; title: string; body: string; -}; -export type FolderMetadata = { +} +export interface DetailedNoteData extends NoteData { + isShared: boolean; +} +export interface FolderData { parentId: ItemId; id: ItemId; title: string; -}; -export type FolderData = FolderMetadata & { - childIds: ItemId[]; - sharedWith: string[]; - // Email of the Joplin Server account that controls the item - ownedByEmail: string; -}; -export type TreeItem = NoteData|FolderData; +} +export interface DetailedFolderData extends FolderData { + isShared: boolean; +} -export const isFolder = (item: TreeItem): item is FolderData => { +export type TreeItem = NoteData|FolderRecord; + +export const isFolder = (item: TreeItem): item is FolderRecord => { return 'childIds' in item; }; // Typescript type assertions require type definitions on the left for arrow functions. // See https://github.com/microsoft/TypeScript/issues/53450. -export const assertIsFolder: (item: TreeItem)=> asserts item is FolderData = item => { +export const assertIsFolder: (item: TreeItem)=> asserts item is FolderRecord = item => { if (!item) { throw new Error(`Item ${item} is not a folder`); } @@ -42,18 +44,28 @@ export const assertIsFolder: (item: TreeItem)=> asserts item is FolderData = ite export interface FuzzContext { serverUrl: string; + isJoplinCloud: boolean; baseDir: string; execApi: (method: HttpMethod, route: string, debugAction: Json)=> Promise; randInt: (low: number, high: number)=> number; } export interface RandomFolderOptions { - filter?: (folder: FolderData)=> boolean; + includeReadOnly: boolean; + filter?: (folder: FolderRecord)=> boolean; +} + +export interface RandomNoteOptions { + includeReadOnly: boolean; +} + +export interface ShareOptions { + readOnly: boolean; } export interface ActionableClient { - createFolder(data: FolderMetadata): Promise; - shareFolder(id: ItemId, shareWith: Client): Promise; + createFolder(data: FolderData): Promise; + shareFolder(id: ItemId, shareWith: Client, options: ShareOptions): Promise; removeFromShare(id: string, shareWith: Client): Promise; deleteFolder(id: ItemId): Promise; createNote(data: NoteData): Promise; @@ -62,10 +74,10 @@ export interface ActionableClient { sync(): Promise; listNotes(): Promise; - listFolders(): Promise; + listFolders(): Promise; allFolderDescendants(parentId: ItemId): Promise; - randomFolder(options: RandomFolderOptions): Promise; - randomNote(): Promise; + randomFolder(options: RandomFolderOptions): Promise; + randomNote(options: RandomNoteOptions): Promise; } export interface UserData {