joplin/packages/tools/fuzzer/Client.ts

421 lines
12 KiB
TypeScript

import uuid, { createSecureRandom } from '@joplin/lib/uuid';
import { ActionableClient, FolderMetadata, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, UserData } from './types';
import { join } from 'path';
import { mkdir } from 'fs-extra';
import getStringProperty from './utils/getStringProperty';
import { strict as assert } from 'assert';
import ClipperServer from '@joplin/lib/ClipperServer';
import ActionTracker from './ActionTracker';
import Logger from '@joplin/utils/Logger';
import execa = require('execa');
import { cliDirectory } from './constants';
import { commandToString } from '@joplin/utils';
import { quotePath } from '@joplin/utils/path';
import getNumberProperty from './utils/getNumberProperty';
import retryWithCount from './utils/retryWithCount';
const logger = Logger.create('Client');
class Client implements ActionableClient {
public readonly email: string;
public static async create(actionTracker: ActionTracker, context: FuzzContext) {
const id = uuid.create();
const profileDirectory = join(context.baseDir, id);
await mkdir(profileDirectory);
const email = `${id}@localhost`;
const password = createSecureRandom();
const apiOutput = await context.execApi('POST', 'api/users', {
email,
});
const serverId = getStringProperty(apiOutput, 'id');
// The password needs to be set *after* creating the user.
const userRoute = `api/users/${encodeURIComponent(serverId)}`;
await context.execApi('PATCH', userRoute, {
email,
password,
email_confirmed: 1,
});
const closeAccount = async () => {
await context.execApi('DELETE', userRoute, {});
};
try {
const userData = {
email: getStringProperty(apiOutput, 'email'),
password,
};
assert.equal(email, userData.email);
const apiToken = createSecureRandom().replace(/[-]/g, '_');
const apiPort = await ClipperServer.instance().findAvailablePort();
const client = new Client(
actionTracker.track({ email }),
userData,
profileDirectory,
apiPort,
apiToken,
closeAccount,
);
// 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', userData.email);
await client.execCliCommand_('config', 'sync.9.password', userData.password);
await client.execCliCommand_('config', 'api.token', apiToken);
await client.execCliCommand_('config', 'api.port', String(apiPort));
const e2eePassword = createSecureRandom().replace(/^-/, '_');
await client.execCliCommand_('e2ee', 'enable', '--password', e2eePassword);
logger.info('Created and configured client');
// Run asynchronously -- the API server command doesn't exit until the server
// is closed.
void (async () => {
try {
await client.execCliCommand_('server', 'start');
} catch (error) {
logger.info('API server exited');
logger.debug('API server exit status', error);
}
})();
await client.sync();
return client;
} catch (error) {
await closeAccount();
throw error;
}
}
private constructor(
private readonly tracker_: ActionableClient,
userData: UserData,
private readonly profileDirectory: string,
private readonly apiPort_: number,
private readonly apiToken_: string,
private readonly cleanUp_: ()=> Promise<void>,
) {
this.email = userData.email;
}
public async close() {
await this.execCliCommand_('server', 'stop');
await this.cleanUp_();
}
private get cliCommandArguments() {
return [
'start-no-build',
'--profile', this.profileDirectory,
'--env', 'dev',
];
}
public getHelpText() {
return [
`Client ${this.email}:`,
`\tCommand: cd ${quotePath(cliDirectory)} && ${commandToString('yarn', this.cliCommandArguments)}`,
].join('\n');
}
private async execCliCommand_(commandName: string, ...args: string[]) {
assert.match(commandName, /^[a-z]/, 'Command name must start with a lowercase letter.');
const commandResult = await execa('yarn', [
...this.cliCommandArguments,
commandName,
...args,
], {
cwd: cliDirectory,
// Connects /dev/null to stdin
stdin: 'ignore',
});
logger.debug('Ran command: ', commandResult.command, commandResult.exitCode);
logger.debug(' Output: ', commandResult.stdout);
return commandResult;
}
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: 'GET', route: string): Promise<Json>;
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: 'POST'|'PUT', route: string, data: Json): Promise<Json>;
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: HttpMethod, route: string, data: Json|null = null): Promise<Json> {
route = route.replace(/^[/]/, '');
const url = new URL(`http://localhost:${this.apiPort_}/${route}`);
url.searchParams.append('token', this.apiToken_);
const response = await fetch(url, {
method,
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
throw new Error(`Request to ${route} failed with error: ${await response.text()}`);
}
return await response.json();
}
private async execPagedApiCommand_<Result>(
method: 'GET',
route: string,
params: Record<string, string>,
deserializeItem: (data: Json)=> Result,
): Promise<Result[]> {
const searchParams = new URLSearchParams(params);
const results: Result[] = [];
let hasMore = true;
for (let page = 1; hasMore; page++) {
searchParams.set('page', String(page));
searchParams.set('limit', '10');
const response = await this.execApiCommand_(
method, `${route}?${searchParams}`,
);
if (
typeof response !== 'object'
|| !('has_more' in response)
|| !('items' in response)
|| !Array.isArray(response.items)
) {
throw new Error(`Invalid response: ${JSON.stringify(response)}`);
}
hasMore = !!response.has_more;
for (const item of response.items) {
results.push(deserializeItem(item));
}
}
return results;
}
private async decrypt_() {
// E2EE decryption can occasionally fail with "Master key is not loaded:".
// Allow e2ee decryption to be retried:
await retryWithCount(async () => {
const result = await this.execCliCommand_('e2ee', 'decrypt', '--force');
if (!result.stdout.includes('Completed decryption.')) {
throw new Error(`Decryption did not complete: ${result.stdout}`);
}
}, {
count: 3,
onFail: async (error)=>{
logger.warn('E2EE decryption failed:', error);
logger.info('Syncing before retry...');
await this.execCliCommand_('sync');
},
});
}
public async sync() {
logger.info('Sync', this.email);
await this.tracker_.sync();
const result = await this.execCliCommand_('sync');
if (result.stdout.match(/Last error:/i)) {
throw new Error(`Sync failed: ${result.stdout}`);
}
await this.decrypt_();
}
public async createFolder(folder: FolderMetadata) {
logger.info('Create folder', folder.id, 'in', `${folder.parentId ?? 'root'}/${this.email}`);
await this.tracker_.createFolder(folder);
await this.execApiCommand_('POST', '/folders', {
id: folder.id,
title: folder.title,
parent_id: folder.parentId ?? '',
});
}
private async assertNoteMatchesState_(expected: NoteData) {
assert.equal(
(await this.execCliCommand_('cat', expected.id)).stdout,
`${expected.title}\n\n${expected.body}`,
'note should exist',
);
}
public async createNote(note: NoteData) {
logger.info('Create note', note.id, 'in', `${note.parentId}/${this.email}`);
await this.tracker_.createNote(note);
await this.execApiCommand_('POST', '/notes', {
id: note.id,
title: note.title,
body: note.body,
parent_id: note.parentId ?? '',
});
await this.assertNoteMatchesState_(note);
}
public async updateNote(note: NoteData) {
logger.info('Update note', note.id, 'in', `${note.parentId}/${this.email}`);
await this.tracker_.updateNote(note);
await this.execApiCommand_('PUT', `/notes/${encodeURIComponent(note.id)}`, {
title: note.title,
body: note.body,
parent_id: note.parentId ?? '',
});
await this.assertNoteMatchesState_(note);
}
public async deleteFolder(id: string) {
logger.info('Delete folder', id, 'in', this.email);
await this.tracker_.deleteFolder(id);
await this.execCliCommand_('rmbook', '--permanent', '--force', id);
}
public async shareFolder(id: string, shareWith: Client) {
await this.tracker_.shareFolder(id, shareWith);
logger.info('Share', id, 'with', shareWith.email);
await this.execCliCommand_('share', 'add', id, shareWith.email);
await this.sync();
await shareWith.sync();
const shareWithIncoming = JSON.parse((await shareWith.execCliCommand_('share', 'list', '--json')).stdout);
const pendingInvitations = shareWithIncoming.invitations.filter((invitation: unknown) => {
if (typeof invitation !== 'object' || !('accepted' in invitation)) {
throw new Error('Invalid invitation format');
}
return !invitation.accepted;
});
assert.deepEqual(pendingInvitations, [
{
accepted: false,
waiting: true,
rejected: false,
folderId: id,
fromUser: {
email: this.email,
},
},
], 'there should be a single incoming share from the expected user');
await shareWith.execCliCommand_('share', 'accept', id);
}
public async moveItem(itemId: ItemId, newParentId: ItemId) {
logger.info('Move', itemId, 'to', newParentId);
await this.tracker_.moveItem(itemId, newParentId);
const movingToRoot = !newParentId;
await this.execCliCommand_('mv', itemId, movingToRoot ? 'root' : newParentId);
}
public async listNotes() {
const params = {
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id',
include_deleted: '1',
include_conflicts: '1',
};
return await this.execPagedApiCommand_(
'GET',
'/notes',
params,
item => ({
id: getStringProperty(item, 'id'),
parentId: getNumberProperty(item, 'is_conflict') === 1 ? (
`[conflicts for ${getStringProperty(item, 'conflict_original_id')} in ${this.email}]`
) : getStringProperty(item, 'parent_id'),
title: getStringProperty(item, 'title'),
body: getStringProperty(item, 'body'),
}),
);
}
public async listFolders() {
const params = {
fields: 'id,parent_id,title',
include_deleted: '1',
};
return await this.execPagedApiCommand_(
'GET',
'/folders',
params,
item => ({
id: getStringProperty(item, 'id'),
parentId: getStringProperty(item, 'parent_id'),
title: getStringProperty(item, 'title'),
}),
);
}
public async randomFolder(options: RandomFolderOptions) {
return this.tracker_.randomFolder(options);
}
public async allFolderDescendants(parentId: ItemId) {
return this.tracker_.allFolderDescendants(parentId);
}
public async randomNote() {
return this.tracker_.randomNote();
}
public async checkState(_allClients: Client[]) {
logger.info('Check state', this.email);
type ItemSlice = { id: string };
const compare = (a: ItemSlice, b: ItemSlice) => {
if (a.id === b.id) return 0;
return a.id < b.id ? -1 : 1;
};
const assertNoAdjacentEqualIds = (sortedById: ItemSlice[], assertionLabel: string) => {
for (let i = 1; i < sortedById.length; i++) {
const current = sortedById[i];
const previous = sortedById[i - 1];
assert.notEqual(
current.id,
previous.id,
`[${assertionLabel}] item ${i} should have a different ID from item ${i - 1}`,
);
}
};
const checkNoteState = async () => {
const notes = [...await this.listNotes()];
const expectedNotes = [...await this.tracker_.listNotes()];
notes.sort(compare);
expectedNotes.sort(compare);
assertNoAdjacentEqualIds(notes, 'notes');
assertNoAdjacentEqualIds(expectedNotes, 'expectedNotes');
assert.deepEqual(notes, expectedNotes, 'should have the same notes as the expected state');
};
const checkFolderState = async () => {
const folders = [...await this.listFolders()];
const expectedFolders = [...await this.tracker_.listFolders()];
folders.sort(compare);
expectedFolders.sort(compare);
assertNoAdjacentEqualIds(folders, 'folders');
assertNoAdjacentEqualIds(expectedFolders, 'expectedFolders');
assert.deepEqual(folders, expectedFolders, 'should have the same folders as the expected state');
};
await checkNoteState();
await checkFolderState();
}
}
export default Client;