You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003)
This commit is contained in:
@@ -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
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
@@ -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,
|
||||
},
|
||||
|
@@ -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<string, ClientData> = 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<ItemId>();
|
||||
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;
|
||||
|
@@ -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() {
|
||||
|
@@ -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<string>;
|
||||
|
||||
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,
|
||||
|
144
packages/tools/fuzzer/model/FolderRecord.ts
Normal file
144
packages/tools/fuzzer/model/FolderRecord.ts
Normal file
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
@@ -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'],
|
||||
});
|
||||
},
|
||||
|
@@ -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<Json>;
|
||||
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<void>;
|
||||
shareFolder(id: ItemId, shareWith: Client): Promise<void>;
|
||||
createFolder(data: FolderData): Promise<void>;
|
||||
shareFolder(id: ItemId, shareWith: Client, options: ShareOptions): Promise<void>;
|
||||
removeFromShare(id: string, shareWith: Client): Promise<void>;
|
||||
deleteFolder(id: ItemId): Promise<void>;
|
||||
createNote(data: NoteData): Promise<void>;
|
||||
@@ -62,10 +74,10 @@ export interface ActionableClient {
|
||||
sync(): Promise<void>;
|
||||
|
||||
listNotes(): Promise<NoteData[]>;
|
||||
listFolders(): Promise<FolderMetadata[]>;
|
||||
listFolders(): Promise<DetailedFolderData[]>;
|
||||
allFolderDescendants(parentId: ItemId): Promise<ItemId[]>;
|
||||
randomFolder(options: RandomFolderOptions): Promise<FolderData>;
|
||||
randomNote(): Promise<NoteData>;
|
||||
randomFolder(options: RandomFolderOptions): Promise<FolderRecord>;
|
||||
randomNote(options: RandomNoteOptions): Promise<NoteData>;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
|
Reference in New Issue
Block a user