mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-11 19:16:16 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8efb34425 | ||
|
|
9c2a9c6d7d | ||
|
|
34696bc462 | ||
|
|
dfe4a2424e | ||
|
|
fbf73e5f6a |
No files matched your search
@@ -6,7 +6,7 @@ import Note from '@joplin/lib/models/Note';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
|
||||
import { ChatRole } from '@joplin/lib/services/ai/types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '@joplin/lib/testing/test-utils';
|
||||
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
|
||||
import ChatPanel from './ChatPanel';
|
||||
import { WindowIdContext } from '../NewWindowOrIFrame';
|
||||
@@ -186,8 +186,10 @@ describe('ChatPanel', () => {
|
||||
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
|
||||
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
|
||||
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
|
||||
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
|
||||
await withWarningSilenced(/Chat failed/, async () => {
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
|
||||
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
|
||||
}, { requireWarning: true });
|
||||
});
|
||||
|
||||
it('should assign an ID before messages are added to a new conversation', async () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('PromptDialog', () => {
|
||||
answer: null,
|
||||
});
|
||||
instance.state = { visible: true, answer: 'some-answer' };
|
||||
// The instance is never mounted, so a real setState() call would trigger a React warning
|
||||
instance.setState = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private property for test setup
|
||||
(instance as unknown as any).menuIsOpened_ = menuIsOpened;
|
||||
|
||||
|
||||
@@ -632,6 +632,38 @@ describe('ItemModel', () => {
|
||||
expect((await models().userItem().byUserId(user1.id)).length).toBe(5);
|
||||
});
|
||||
|
||||
test('should process orphaned items mixed with valid items across batches', async () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
const { user: user2 } = await createUserAndSession(2);
|
||||
|
||||
await createItemTree3(user1.id, '', '', [
|
||||
{ id: '000000000000000000000000000000F1' },
|
||||
]);
|
||||
|
||||
await createItemTree3(user2.id, '', '', [
|
||||
{ id: '000000000000000000000000000000F2' },
|
||||
{ id: '000000000000000000000000000000F3' },
|
||||
{ id: '000000000000000000000000000000F4' },
|
||||
{ id: '000000000000000000000000000000F5' },
|
||||
]);
|
||||
|
||||
const f4 = await models().item().loadByJopId(user2.id, '000000000000000000000000000000F4');
|
||||
await db()('user_items').where('item_id', '=', f4.id).delete();
|
||||
await db()('users').where('id', '=', user1.id).delete();
|
||||
await db()('user_items').where('user_id', '=', user1.id).delete();
|
||||
|
||||
await models().item().processOrphanedItems({ batchSize: 2 });
|
||||
|
||||
const items = await models().item().all();
|
||||
expect(items.map(i => i.name).sort()).toEqual([
|
||||
'000000000000000000000000000000F2.md',
|
||||
'000000000000000000000000000000F3.md',
|
||||
'000000000000000000000000000000F4.md',
|
||||
'000000000000000000000000000000F5.md',
|
||||
]);
|
||||
expect((await models().userItem().byUserId(user2.id)).length).toBe(4);
|
||||
});
|
||||
|
||||
test('should return multiple item contents', async () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
|
||||
|
||||
@@ -1102,6 +1102,10 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
// then look up which of those ids exist in user_items. The orphan
|
||||
// filter happens in TS. Per-query cost is bounded by the window size,
|
||||
// not by the table size, so no statement can blow past the timeout.
|
||||
//
|
||||
// The window query only selects `id` so that it is an index-only scan.
|
||||
// IDs are random, so any other column costs one heap page read per row,
|
||||
// and walking the whole heap evicts the buffer cache used by the app.
|
||||
const batchSize = options.batchSize ?? 10000;
|
||||
let batchNum = 0;
|
||||
let totalProcessed = 0;
|
||||
@@ -1114,7 +1118,7 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
const batchStartTime = Date.now();
|
||||
|
||||
const windowItems: Item[] = await this.db(this.tableName)
|
||||
.select(['items.id', 'items.name', 'items.owner_id'])
|
||||
.select(['items.id'])
|
||||
.where('items.id', '>', lastId)
|
||||
.orderBy('items.id')
|
||||
.limit(batchSize);
|
||||
@@ -1133,14 +1137,18 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
.select('item_id')
|
||||
.whereIn('item_id', windowItemIds);
|
||||
const userItemIds = new Set(existingUserItems.map(u => u.item_id));
|
||||
const orphanedItems = windowItems.filter(i => !userItemIds.has(i.id));
|
||||
const orphanedItemIds = windowItemIds.filter(id => !userItemIds.has(id));
|
||||
|
||||
if (!orphanedItems.length) {
|
||||
if (!orphanedItemIds.length) {
|
||||
const batchDuration = Date.now() - batchStartTime;
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - No orphans in window of ${windowItems.length} items (${batchDuration}ms)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orphanedItems: Item[] = await this.db(this.tableName)
|
||||
.select(['id', 'name', 'owner_id'])
|
||||
.whereIn('id', orphanedItemIds);
|
||||
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Found ${orphanedItems.length} orphaned items`);
|
||||
for (const o of orphanedItems) {
|
||||
modelLogger.info(`OrphanTrace: found orphan item=${o.id} owner=${o.owner_id} name=${o.name}`);
|
||||
@@ -1172,7 +1180,7 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
const restoreCount = Array.from(itemsToRestoreByUser.values()).reduce((sum, items) => sum + items.length, 0);
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Restoring ${restoreCount} items for ${itemsToRestoreByUser.size} users`);
|
||||
for (const [userId, items] of itemsToRestoreByUser) {
|
||||
await this.models().userItem().addMulti(userId, items);
|
||||
await this.models().userItem().addMulti(userId, items, { ignoreAlreadyExists: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ describe('shares', () => {
|
||||
const result = await getApi<Share>('', `shares/${share.id}`);
|
||||
expect(result.id).toBe(share.id);
|
||||
expect(result.type).toBe(ShareType.PublishedFolder);
|
||||
expect(result.owner_id).toBeUndefined();
|
||||
|
||||
const ownerResult = await getApi<Share>(session.id, `shares/${share.id}`);
|
||||
expect(ownerResult.owner_id).toBe(share.owner_id);
|
||||
});
|
||||
|
||||
test('should not reveal whether an email has an account', async () => {
|
||||
|
||||
@@ -132,7 +132,10 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
if (share && (share.type === ShareType.Note || share.type === ShareType.PublishedFolder)) {
|
||||
// No authentication is necessary - anyone who knows the share ID is allowed
|
||||
// to access the file. It is essentially public.
|
||||
return shareModel.toApiOutput(share);
|
||||
const output = await shareModel.toApiOutput(share) as Share;
|
||||
// Published links are indexed by search engines, and the owner's ID would let anyone target their account
|
||||
if (ctx.joplin.owner?.id !== share.owner_id) delete output.owner_id;
|
||||
return output;
|
||||
}
|
||||
|
||||
throw new ErrorNotFound();
|
||||
|
||||
@@ -222,6 +222,9 @@ describe('index/users', () => {
|
||||
// "confirm_email" is set to 0.
|
||||
user1 = await models().user().load(user1.id);
|
||||
expect(user1.email_confirmed).toBe(0);
|
||||
|
||||
// The token is still required
|
||||
await expectHttpError(() => execRequest('', 'GET', path, null, { query: { token: 'invalid', confirm_email: '0' } }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
test('should allow user to verify their email', async () => {
|
||||
|
||||
@@ -166,7 +166,12 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.query.confirm_email !== '0') await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
|
||||
if (ctx.query.confirm_email !== '0') {
|
||||
await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
|
||||
} else {
|
||||
// Otherwise this page would reveal whether an account exists, and the email of an account without a password
|
||||
await models.token().checkToken(userId, token, TokenPurpose.AccountConfirmation);
|
||||
}
|
||||
|
||||
const user = await models.user().load(userId);
|
||||
if (!user) throw new ErrorNotFound(`No such user: ${userId}`);
|
||||
|
||||
@@ -63,10 +63,12 @@ export default async function(env: Env, models: Models, config: Config, services
|
||||
run: (models: Models) => models.user().handleOversizedAccounts(),
|
||||
},
|
||||
|
||||
// Walks the whole items table, so it runs once a day rather than
|
||||
// hourly. Orphans are rare and clients work around them meanwhile.
|
||||
{
|
||||
id: TaskId.ProcessOrphanedItems,
|
||||
description: taskIdToLabel(TaskId.ProcessOrphanedItems),
|
||||
schedule: '15 * * * *',
|
||||
schedule: '15 4 * * *',
|
||||
run: (models: Models) => models.item().processOrphanedItems(),
|
||||
},
|
||||
|
||||
|
||||
+20
-10
@@ -1,20 +1,35 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createTempDir } from './fs.test';
|
||||
import { newHttpError, sendMessage, startServer, stopServer } from './ipc';
|
||||
import { IpcMessageHandler, IpcServer, newHttpError, sendMessage, startServer as startServerBase, stopServer } from './ipc';
|
||||
|
||||
const getRandomPort = () => {
|
||||
return Math.floor(Math.random() * (65535 - 20000 + 1)) + 20000;
|
||||
// Stay below the OS ephemeral port ranges (Linux: 32768+, macOS/Windows: 49152+), otherwise an outgoing
|
||||
// connection may hold the port, which `tcp-port-used` reports as free but `listen()` rejects with EADDRINUSE.
|
||||
return Math.floor(Math.random() * 20000) + 10000;
|
||||
};
|
||||
|
||||
let startedServers: IpcServer[] = [];
|
||||
|
||||
const startServer = async (startPort: number, secretKeyFilePath: string, messageHandler: IpcMessageHandler) => {
|
||||
const server = await startServerBase(startPort, secretKeyFilePath, messageHandler);
|
||||
startedServers.push(server);
|
||||
return server;
|
||||
};
|
||||
|
||||
describe('ipc', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of startedServers) await stopServer(server);
|
||||
startedServers = [];
|
||||
});
|
||||
|
||||
it('should send and receive messages', async () => {
|
||||
const tempDir = await createTempDir();
|
||||
const secretFilePath = `${tempDir}/secret.txt`;
|
||||
const serverPort1 = getRandomPort();
|
||||
const serverPort2 = serverPort1 + 5;
|
||||
|
||||
const server1 = await startServer(serverPort1, secretFilePath, async (request) => {
|
||||
await startServer(serverPort1, secretFilePath, async (request) => {
|
||||
if (request.action === 'testing') {
|
||||
return {
|
||||
text: 'hello1',
|
||||
@@ -24,7 +39,7 @@ describe('ipc', () => {
|
||||
throw newHttpError(404);
|
||||
});
|
||||
|
||||
const server2 = await startServer(serverPort2, secretFilePath, async (request) => {
|
||||
await startServer(serverPort2, secretFilePath, async (request) => {
|
||||
if (request.action === 'testing') {
|
||||
return {
|
||||
text: 'hello2',
|
||||
@@ -83,9 +98,6 @@ describe('ipc', () => {
|
||||
{ port: serverPort2, response: { text: 'hello2' } },
|
||||
]);
|
||||
}
|
||||
|
||||
await stopServer(server1);
|
||||
await stopServer(server2);
|
||||
});
|
||||
|
||||
it('should not process message if secret is invalid', async () => {
|
||||
@@ -93,7 +105,7 @@ describe('ipc', () => {
|
||||
const secretFilePath = `${tempDir}/secret.txt`;
|
||||
const serverPort = getRandomPort();
|
||||
|
||||
const server = await startServer(serverPort, secretFilePath, async (request) => {
|
||||
await startServer(serverPort, secretFilePath, async (request) => {
|
||||
if (request.action === 'testing') {
|
||||
return {
|
||||
text: 'hello1',
|
||||
@@ -124,8 +136,6 @@ describe('ipc', () => {
|
||||
|
||||
expect(responses.length).toBe(1);
|
||||
}
|
||||
|
||||
await stopServer(server);
|
||||
});
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user