pull/9085/head
Laurent Cozic 2023-10-18 17:54:29 +01:00
parent 8b811111d6
commit c19f9eb705
10 changed files with 251 additions and 112 deletions

View File

@ -288,7 +288,7 @@ PODS:
- React-Core
- react-native-get-random-values (1.9.0):
- React-Core
- react-native-image-picker (5.6.1):
- react-native-image-picker (5.7.0):
- React-Core
- react-native-image-resizer (3.0.7):
- React-Core
@ -418,11 +418,11 @@ PODS:
- React-Core
- RNVectorIcons (10.0.0):
- React-Core
- RNZipArchive (6.0.9):
- RNZipArchive (6.1.0):
- React-Core
- RNZipArchive/Core (= 6.0.9)
- RNZipArchive/Core (= 6.1.0)
- SSZipArchive (~> 2.2)
- RNZipArchive/Core (6.0.9):
- RNZipArchive/Core (6.1.0):
- React-Core
- SSZipArchive (~> 2.2)
- SSZipArchive (2.4.3)
@ -669,7 +669,7 @@ SPEC CHECKSUMS:
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
react-native-image-picker: 5fcac5a5ffcb3737837f0617d43fd767249290de
react-native-image-picker: 3269f75c251cdcd61ab51b911dd30d6fff8c6169
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
@ -705,7 +705,7 @@ SPEC CHECKSUMS:
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6
RNVectorIcons: 8b5bb0fa61d54cd2020af4f24a51841ce365c7e9
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
RNZipArchive: ef9451b849c45a29509bf44e65b788829ab07801
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
Yoga: e7ea9e590e27460d28911403b894722354d73479

View File

@ -61,6 +61,7 @@
"devDependencies": {
"@joplin/tools": "~2.13",
"@rmp135/sql-ts": "1.18.0",
"@types/bcryptjs": "2.4.5",
"@types/formidable": "3.4.3",
"@types/fs-extra": "11.0.2",
"@types/jest": "29.5.4",

View File

@ -98,7 +98,7 @@ export const up = async (db: DbConnection) => {
await db('users').insert({
id: adminId,
email: defaultAdminEmail,
password: hashPassword(defaultAdminPassword),
password: await hashPassword(defaultAdminPassword),
full_name: 'Admin',
is_admin: 1,
updated_time: now,

View File

@ -125,7 +125,7 @@ export default class UserModel extends BaseModel<User> {
public async login(email: string, password: string): Promise<User> {
const user = await this.loadByEmail(email);
if (!user) return null;
if (!checkPassword(password, user.password)) return null;
if (!(await checkPassword(password, user.password))) return null;
return user;
}
@ -635,15 +635,22 @@ export default class UserModel extends BaseModel<User> {
public async save(object: User, options: SaveOptions = {}): Promise<User> {
const user = this.formatValues(object);
const isNew = await this.isNew(object, options);
if (user.password) {
if (isHashedPassword(user.password)) {
if (!isNew) {
throw new ErrorBadRequest(`Unable to save user because password already seems to be hashed. User id: ${user.id}`);
} else {
// OK - We allow supplying an already hashed password for
// new users. This is mostly used for testing, because
// generating a bcrypt hash for each user is slow.
}
} else {
if (!options.skipValidation) this.validatePassword(user.password);
user.password = hashPassword(user.password);
user.password = await hashPassword(user.password);
}
}
const isNew = await this.isNew(object, options);
return this.withTransaction(async () => {
const savedUser = await super.save(user, options);

View File

@ -5,7 +5,7 @@ export function unique(array: any[]): any[] {
}
export const randomElement = <T>(array: T[]): T => {
if (!array.length) return null;
if (!array || !array.length) return null;
return array[Math.floor(Math.random() * array.length)];
};

View File

@ -12,7 +12,7 @@ describe('hashPassword', () => {
'$2a$10$LMKVPiNOWDZhtw9NizNIEuNGLsjOxQAcrwQJ0lnKuiaOtyFgZEnwO',
],
)('should return a string that starts with $2a$10 for the password: %', async (plainText) => {
expect(hashPassword(plainText).startsWith('$2a$10')).toBe(true);
expect((await hashPassword(plainText)).startsWith('$2a$10')).toBe(true);
});
});

View File

@ -1,12 +1,12 @@
const bcrypt = require('bcryptjs');
import * as bcrypt from 'bcryptjs';
export function hashPassword(password: string): string {
const salt = bcrypt.genSaltSync(10);
return bcrypt.hashSync(password, salt);
export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
export function checkPassword(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash);
export async function checkPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export const isHashedPassword = (password: string) => {

View File

@ -1,8 +1,10 @@
import { NoteEntity } from '@joplin/lib/services/database/types';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import { randomElement, removeElement } from '../array';
import { User } from '../../services/database/types';
import { randomElement } from '../array';
import { CustomErrorCode } from '../errors';
import { randomWords } from './randomWords';
import { afterAllTests, beforeAllDb, createdDbPath, createFolder, createNote, createResource, createUserAndSession, deleteItem, randomHash, updateFolder, updateNote, UserAndSession } from './testUtils';
import { afterAllTests, beforeAllDb, createdDbPath, makeFolderSerializedBody, makeNoteSerializedBody, makeResourceSerializedBody, models, randomHash } from './testUtils';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const nodeSqlite = require('sqlite3');
@ -57,114 +59,191 @@ const isDeleteAction = (action: Action) => {
return deleteActions.includes(action);
};
type Reaction = (context: Context, sessionId: string)=> Promise<void>;
type Reaction = (context: Context, user: User)=> Promise<boolean>;
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const createRandomNote = (sessionId: string, note: NoteEntity = null) => {
return createNote(sessionId, {
id: randomHash(),
const createRandomNote = async (user: User, note: NoteEntity = null) => {
const id = randomHash();
const itemName = `${id}.md`;
const serializedBody = makeNoteSerializedBody({
id,
title: randomWords(randomInt(1, 10)),
...note,
});
const result = await models().item().saveFromRawContent(user, {
name: itemName,
body: Buffer.from(serializedBody),
});
if (result[itemName].error) throw result[itemName].error;
return result[itemName].item;
};
const createRandomFolder = async (user: User, folder: FolderEntity = null) => {
const id = randomHash();
const itemName = `${id}.md`;
const serializedBody = makeFolderSerializedBody({
id,
title: randomWords(randomInt(1, 5)),
...folder,
});
const result = await models().item().saveFromRawContent(user, {
name: itemName,
body: Buffer.from(serializedBody),
});
if (result[itemName].error) throw result[itemName].error;
return result[itemName].item;
};
const reactions: Record<Action, Reaction> = {
[Action.CreateNote]: async (context, sessionId) => {
const item = await createRandomNote(sessionId);
if (!context.createdNoteIds[sessionId]) context.createdNoteIds[sessionId] = [];
context.createdNoteIds[sessionId].push(item.jop_id);
[Action.CreateNote]: async (context, user) => {
const item = await createRandomNote(user);
if (!context.createdNoteIds[user.id]) context.createdNoteIds[user.id] = [];
context.createdNoteIds[user.id].push(item.jop_id);
return true;
},
[Action.CreateFolder]: async (context, sessionId) => {
const item = await createFolder(sessionId, {
id: randomHash(),
title: randomWords(randomInt(1, 5)),
});
if (!context.createdFolderIds[sessionId]) context.createdFolderIds[sessionId] = [];
context.createdFolderIds[sessionId].push(item.jop_id);
[Action.CreateFolder]: async (context, user) => {
const item = await createRandomFolder(user);
if (!context.createdFolderIds[user.id]) context.createdFolderIds[user.id] = [];
context.createdFolderIds[user.id].push(item.jop_id);
return true;
},
[Action.CreateNoteAndResource]: async (context, sessionId) => {
const item = await createResource(sessionId, {
id: randomHash(),
title: randomWords(randomInt(1, 5)),
}, randomWords(randomInt(10, 100)));
[Action.CreateNoteAndResource]: async (context, user) => {
const resourceContent = randomWords(20);
const resourceId = randomHash();
if (!context.createdResourceIds[sessionId]) context.createdResourceIds[sessionId] = [];
context.createdResourceIds[sessionId].push(item.jop_id);
const noteItem = await createRandomNote(sessionId, {
body: `[](:/${item.jop_id})`,
const metadataBody = makeResourceSerializedBody({
id: resourceId,
title: randomWords(5),
size: resourceContent.length,
});
if (!context.createdNoteIds[sessionId]) context.createdNoteIds[sessionId] = [];
context.createdNoteIds[sessionId].push(noteItem.jop_id);
},
[Action.UpdateNote]: async (context, sessionId) => {
const noteId = randomElement(context.createdNoteIds[sessionId]);
if (!noteId) return;
await updateNote(sessionId, {
id: noteId,
title: randomWords(randomInt(1, 10)),
await models().item().saveFromRawContent(user, {
name: `${resourceId}.md`,
body: Buffer.from(metadataBody),
});
},
[Action.UpdateFolder]: async (context, sessionId) => {
const folderId = randomElement(context.createdFolderIds[sessionId]);
if (!folderId) return;
await updateFolder(sessionId, {
id: folderId,
title: randomWords(randomInt(1, 10)),
await models().item().saveFromRawContent(user, {
name: `.resource/${resourceId}`,
body: Buffer.from(resourceContent),
});
if (!context.createdResourceIds[user.id]) context.createdResourceIds[user.id] = [];
context.createdResourceIds[user.id].push(resourceId);
const noteItem = await createRandomNote(user, {
body: `[](:/${resourceId})`,
});
if (!context.createdNoteIds[user.id]) context.createdNoteIds[user.id] = [];
context.createdNoteIds[user.id].push(noteItem.jop_id);
return true;
},
[Action.DeleteNote]: async (context, sessionId) => {
const noteId = randomElement(context.createdNoteIds[sessionId]);
if (!noteId) return;
removeElement(context.createdNoteIds[sessionId], noteId);
await deleteItem(sessionId, noteId);
[Action.UpdateNote]: async (context, user) => {
const noteId = randomElement(context.createdNoteIds[user.id]);
if (!noteId) return false;
try {
const noteItem = await models().item().loadByJopId(user.id, noteId);
const note = await models().item().loadAsJoplinItem(noteItem.id);
const serialized = makeNoteSerializedBody({
title: randomWords(10),
...note,
});
await models().item().saveFromRawContent(user, {
name: `${note.id}.md`,
body: Buffer.from(serialized),
});
} catch (error) {
if (error.code === CustomErrorCode.NotFound) return false;
throw error;
}
return true;
},
[Action.DeleteFolder]: async (context, sessionId) => {
const folderId = randomElement(context.createdFolderIds[sessionId]);
if (!folderId) return;
removeElement(context.createdFolderIds[sessionId], folderId);
await deleteItem(sessionId, folderId);
[Action.UpdateFolder]: async (context, user) => {
const folderId = randomElement(context.createdFolderIds[user.id]);
if (!folderId) return false;
try {
const folderItem = await models().item().loadByJopId(user.id, folderId);
const folder = await models().item().loadAsJoplinItem(folderItem.id);
const serialized = makeFolderSerializedBody({
title: randomWords(5),
...folder,
});
await models().item().saveFromRawContent(user, {
name: `${folder.id}.md`,
body: Buffer.from(serialized),
});
} catch (error) {
if (error.code === CustomErrorCode.NotFound) return false;
throw error;
}
return true;
},
[Action.DeleteNote]: async (context, user) => {
const noteId = randomElement(context.createdNoteIds[user.id]);
if (!noteId) return false;
const item = await models().item().loadByJopId(user.id, noteId, { fields: ['id'] });
await models().item().delete(item.id, { allowNoOp: true });
return true;
},
[Action.DeleteFolder]: async (context, user) => {
const folderId = randomElement(context.createdFolderIds[user.id]);
if (!folderId) return false;
const item = await models().item().loadByJopId(user.id, folderId, { fields: ['id'] });
await models().item().delete(item.id, { allowNoOp: true });
return true;
},
};
const randomActionKey = () => {
return randomElement(Object.keys(reactions)) as Action;
const r = Math.random();
if (r <= .5) {
return randomElement(createActions);
} else if (r <= .8) {
return randomElement(updateActions);
} else {
return randomElement(deleteActions);
}
};
const main = async (options?: Options) => {
options = {
userCount: 10,
minNoteCountPerUser: 0,
maxNoteCountPerUser: 1000,
minFolderCountPerUser: 0,
maxFolderCountPerUser: 50,
...options,
};
const main = async (_options?: Options) => {
// options = {
// userCount: 10,
// minNoteCountPerUser: 0,
// maxNoteCountPerUser: 1000,
// minFolderCountPerUser: 0,
// maxFolderCountPerUser: 50,
// ...options,
// };
shimInit({ nodeSqlite });
await beforeAllDb('populateDatabase');
logger().info(`Populating database: ${createdDbPath()}`);
const userAndSessions: UserAndSession[] = [];
for (let i = 0; i < options.userCount; i++) {
logger().info(`Creating user ${i}`);
userAndSessions.push(await createUserAndSession(i, false));
}
const context: Context = {
createdNoteIds: {},
createdFolderIds: {},
@ -183,39 +262,74 @@ const main = async (options?: Options) => {
if (isDeleteAction(action)) report.deleted++;
};
let users: User[] = [];
// -------------------------------------------------------------
// CREATE USERS
// -------------------------------------------------------------
{
const promises = [];
for (let i = 0; i < 1000; i++) {
const userAndSession = randomElement(userAndSessions);
const key = randomElement(createActions);
updateReport(key);
promises.push((async () => {
await reactions[key](context, userAndSession.session.id);
logger().info(`Done action ${i}: ${key}. User: ${userAndSession.user.email}`);
const user = await models().user().save({
full_name: `Toto ${i}`,
email: `toto${i}@example.com`,
password: '$2a$10$/2DMDnrx0PAspJ2DDnW/PO5x5M9H1abfSPsqxlPMhYiXgDi25751u', // Password = 111111
});
users.push(user);
logger().info(`Created user ${i}`);
})());
}
await Promise.all(promises);
}
users = await models().user().loadByIds(users.map(u => u.id));
// -------------------------------------------------------------
// CREATE NOTES, FOLDERS AND RESOURCES
// -------------------------------------------------------------
{
let promises = [];
const promises = [];
for (let i = 0; i < 1000; i++) {
const userAndSession = randomElement(userAndSessions);
const key = randomActionKey();
updateReport(key);
promises.push((async () => {
await reactions[key](context, userAndSession.session.id);
logger().info(`Done action ${i}: ${key}. User: ${userAndSession.user.email}`);
const user = randomElement(users);
const action = randomElement(createActions);
await reactions[action](context, user);
updateReport(action);
logger().info(`Done action ${i}: ${action}. User: ${user.email}`);
})());
if (promises.length > 100) {
await Promise.all(promises);
promises = [];
}
await Promise.all(promises);
}
// -------------------------------------------------------------
// CREATE/UPDATE/DELETE NOTES, FOLDERS AND RESOURCES
// -------------------------------------------------------------
{
const promises = [];
for (let i = 0; i < 10000; i++) {
promises.push((async () => {
const user = randomElement(users);
const action = randomActionKey();
try {
const done = await reactions[action](context, user);
if (done) updateReport(action);
logger().info(`Done action ${i}: ${action}. User: ${user.email}${!done ? ' (Skipped)' : ''}`);
} catch (error) {
error.message = `Could not do action ${i}: ${action}. User: ${user.email}: ${error.message}`;
throw error;
}
})());
}
await Promise.all(promises);

View File

@ -570,7 +570,16 @@ type_: 2`;
}
export function makeResourceSerializedBody(resource: ResourceEntity = {}): string {
return `Test Resource
resource = {
id: randomHash(),
mime: 'plain/text',
file_extension: 'txt',
size: 0,
title: 'Test Resource',
...resource,
};
return `${resource.title}
id: ${resource.id}
mime: ${resource.mime}

View File

@ -5136,6 +5136,7 @@ __metadata:
"@joplin/utils": ~2.13
"@koa/cors": 3.4.3
"@rmp135/sql-ts": 1.18.0
"@types/bcryptjs": 2.4.5
"@types/formidable": 3.4.3
"@types/fs-extra": 11.0.2
"@types/jest": 29.5.4
@ -7721,6 +7722,13 @@ __metadata:
languageName: node
linkType: hard
"@types/bcryptjs@npm:2.4.5":
version: 2.4.5
resolution: "@types/bcryptjs@npm:2.4.5"
checksum: f721d72d8e1374ee2a342ce90cc902e2308cd059317af6e663d752537e704ea73bb119a2d34a6a68475f80abc1342635f48570119e0381f83a202724974f1e9f
languageName: node
linkType: hard
"@types/body-parser@npm:*":
version: 1.19.2
resolution: "@types/body-parser@npm:1.19.2"