mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-07-12 10:49:08 +00:00
Some checks failed
Build and push Joplock image / build-and-push (push) Has been cancelled
3703 lines
141 KiB
JavaScript
3703 lines
141 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const http = require('http');
|
||
const { createRateLimitService } = require('../app/auth/rateLimitService');
|
||
const { createServer } = require('../app/createServer');
|
||
|
||
const request = (port, options = {}) => {
|
||
const {
|
||
path: requestPath = '/api/web/me',
|
||
method = 'GET',
|
||
headers = { Cookie: 'sessionId=test-session' },
|
||
body = null,
|
||
rawBody = null,
|
||
} = options;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
hostname: '127.0.0.1',
|
||
port,
|
||
path: requestPath,
|
||
method,
|
||
headers,
|
||
}, res => {
|
||
const chunks = [];
|
||
res.on('data', chunk => chunks.push(chunk));
|
||
res.on('end', () => {
|
||
const buf = Buffer.concat(chunks);
|
||
resolve({ statusCode: res.statusCode, body: buf.toString('utf8'), rawBody: buf, headers: res.headers });
|
||
});
|
||
});
|
||
if (rawBody) {
|
||
req.write(rawBody);
|
||
} else if (body) {
|
||
req.write(body);
|
||
}
|
||
req.end();
|
||
});
|
||
};
|
||
|
||
const makePublicDir = () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-public-'));
|
||
// Copy htmx.min.js stub so static file serving works
|
||
fs.writeFileSync(path.join(dir, 'htmx.min.js'), '// stub');
|
||
return dir;
|
||
};
|
||
|
||
const defaultMocks = (overrides = {}) => ({
|
||
publicDir: overrides.publicDir || makePublicDir(),
|
||
joplinPublicBasePath: overrides.joplinPublicBasePath !== undefined ? overrides.joplinPublicBasePath : '/joplin',
|
||
joplinPublicBaseUrl: overrides.joplinPublicBaseUrl || 'http://localhost:5444',
|
||
joplinServerPublicUrl: overrides.joplinServerPublicUrl || 'http://localhost:5444/joplin',
|
||
joplinServerOrigin: overrides.joplinServerOrigin || 'http://server:22300',
|
||
adminService: overrides.adminService || null,
|
||
adminEmail: overrides.adminEmail || '',
|
||
ignoreAdminMfa: overrides.ignoreAdminMfa || false,
|
||
itemService: {
|
||
foldersByUserId: async () => [],
|
||
folderByUserIdAndJopId: async () => null,
|
||
notesByUserId: async () => [],
|
||
noteHeadersByUserId: async () => [],
|
||
noteHeadersByFolder: async () => [],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 0], ['__trash__', 0]]),
|
||
noteByUserIdAndJopId: async () => null,
|
||
searchNotes: async () => [],
|
||
resourceBlobByUserId: async () => null,
|
||
resourceMetaByUserId: async () => null,
|
||
...overrides.itemService,
|
||
},
|
||
itemWriteService: {
|
||
createFolder: async () => ({ id: 'folder-created' }),
|
||
deleteFolder: async () => {},
|
||
updateFolder: async () => ({ id: 'folder-updated' }),
|
||
createNote: async () => ({ id: 'note-created' }),
|
||
deleteNote: async () => {},
|
||
trashNote: async () => {},
|
||
restoreNote: async () => {},
|
||
updateNote: async () => ({ id: 'note-updated' }),
|
||
createResource: async () => ({ id: 'res-created' }),
|
||
...overrides.itemWriteService,
|
||
},
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'test-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
return null;
|
||
},
|
||
touchSession: async () => {},
|
||
getLastSeen: async () => null,
|
||
deleteSession: async () => {},
|
||
...overrides.sessionService,
|
||
},
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'MMM-DD-YY', datetimeFormat: 'YYYY-MM-DD HH:mm', autoLogout: false, autoLogoutMinutes: 15 }),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
appSettings: async () => ({ authRateLimitAttempts: 20 }),
|
||
saveAppSettings: async settings => settings,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
...overrides.settingsService,
|
||
},
|
||
historyService: {
|
||
saveSnapshot: async () => {},
|
||
listSnapshots: async () => [],
|
||
getSnapshot: async () => null,
|
||
...overrides.historyService,
|
||
},
|
||
backupService: overrides.backupService || {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [],
|
||
startBackupJob: async () => ({ name: 'joplock-backup.dump', state: 'running', type: 'backup' }),
|
||
backupPath: async () => ({ path: path.join(os.tmpdir(), 'joplock-test-backup.dump'), size: 4, name: 'joplock-test-backup.dump', createdTime: Date.now() }),
|
||
startRestoreJob: async () => ({ name: 'joplock-test-backup.dump', state: 'running', type: 'restore' }),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
recoveryService: overrides.recoveryService || {
|
||
isEnabled: () => false,
|
||
createSession: () => '',
|
||
validateSession: () => false,
|
||
endSession: () => {},
|
||
},
|
||
rateLimitService: overrides.rateLimitService,
|
||
sessionCookieMaxAge: overrides.sessionCookieMaxAge || 31536000,
|
||
vaultService: overrides.vaultService || null,
|
||
database: overrides.database || { query: async () => ({ rows: [] }) },
|
||
});
|
||
|
||
const withServer = async (mocks, fn) => {
|
||
const server = createServer(defaultMocks(mocks));
|
||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
const port = server.address().port;
|
||
try {
|
||
await fn(port);
|
||
} finally {
|
||
await new Promise(resolve => server.close(resolve));
|
||
}
|
||
};
|
||
|
||
// --- JSON API tests ---
|
||
|
||
test('GET /api/web/me returns current user for valid session', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port);
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.user.email, 'user@example.com');
|
||
});
|
||
});
|
||
|
||
test('GET /api/web/me returns 401 for invalid session', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { headers: { Cookie: 'sessionId=bad' } });
|
||
assert.equal(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('GET /api/web/folders returns folders', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Test', parentId: '', createdTime: 0, updatedTime: 0 }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/folders' });
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.items.length, 1);
|
||
assert.equal(payload.items[0].id, 'f1');
|
||
});
|
||
});
|
||
|
||
test('POST /api/web/folders creates folder', async () => {
|
||
let createdFolder = null;
|
||
await withServer({
|
||
itemWriteService: {
|
||
createFolder: async (_sid, folder) => { createdFolder = folder; return { id: 'f-new' }; },
|
||
},
|
||
itemService: {
|
||
folderByUserIdAndJopId: async () => ({ id: 'f-new', title: 'New', parentId: '' }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/folders',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: 'New' }),
|
||
});
|
||
assert.equal(res.statusCode, 201);
|
||
assert.equal(createdFolder.title, 'New');
|
||
});
|
||
});
|
||
|
||
test('DELETE /api/web/folders/:id moves notes to General before deleting notebook', async () => {
|
||
const moved = [];
|
||
let createdFolder = null;
|
||
let deletedId = null;
|
||
await withServer({
|
||
itemService: {
|
||
folderByUserIdAndJopId: async (_uid, id) => id === 'f1' ? { id: 'f1', title: 'Work', parentId: '' } : null,
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Work', parentId: '' }],
|
||
notesByUserId: async (_uid, options = {}) => options.folderId === 'f1' ? [
|
||
{ id: 'n1', title: 'Note 1', body: 'A', parentId: 'f1', createdTime: 1 },
|
||
{ id: 'n2', title: 'Note 2', body: 'B', parentId: 'f1', createdTime: 2 },
|
||
] : [],
|
||
},
|
||
itemWriteService: {
|
||
createFolder: async (_sid, folder) => { createdFolder = folder; return { id: 'general-id' }; },
|
||
updateNote: async (_sid, existing, updates) => { moved.push({ id: existing.id, parentId: updates.parentId }); return { id: existing.id }; },
|
||
deleteFolder: async (_sid, id) => { deletedId = id; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/folders/f1',
|
||
method: 'DELETE',
|
||
});
|
||
assert.equal(res.statusCode, 204);
|
||
assert.deepEqual(createdFolder, { title: 'General', parentId: '' });
|
||
assert.deepEqual(moved, [
|
||
{ id: 'n1', parentId: 'general-id' },
|
||
{ id: 'n2', parentId: 'general-id' },
|
||
]);
|
||
assert.equal(deletedId, 'f1');
|
||
});
|
||
});
|
||
|
||
test('PUT /api/web/notes/:id updates note', async () => {
|
||
const existing = { id: 'n1', title: 'Old', body: 'Old body', parentId: 'f1', createdTime: 1000 };
|
||
let updateArgs = null;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => existing,
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async (_sid, ex, updates) => { updateArgs = { ex, updates }; return { id: ex.id }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/notes/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: 'New', body: 'New body' }),
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(updateArgs.updates.title, 'New');
|
||
assert.equal(updateArgs.updates.body, 'New body');
|
||
});
|
||
});
|
||
|
||
test('PUT /api/web/notes/:id returns 404 for missing note', async () => {
|
||
await withServer({
|
||
itemService: { noteByUserIdAndJopId: async () => null },
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/notes/missing',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: 'X' }),
|
||
});
|
||
assert.equal(res.statusCode, 404);
|
||
});
|
||
});
|
||
|
||
test('POST /api/web/notes rejects plaintext create inside vault', async () => {
|
||
let created = false;
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async () => { created = true; return { id: 'n1' }; },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/notes',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: 'Secret', body: 'plain body', parentId: 'vault-1' }),
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.equal(created, false);
|
||
assert.ok(JSON.parse(res.body).error.includes('Vault notes must be saved encrypted'));
|
||
});
|
||
});
|
||
|
||
test('PUT /api/web/notes/:id rejects plaintext update into vault', async () => {
|
||
let updated = false;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Draft', body: 'plain body', parentId: 'f1', createdTime: 1000 }),
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async () => { updated = true; return { id: 'n1' }; },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/notes/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: 'Draft', body: 'plain body', parentId: 'vault-1' }),
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.equal(updated, false);
|
||
assert.ok(JSON.parse(res.body).error.includes('Vault notes must be saved encrypted'));
|
||
});
|
||
});
|
||
|
||
// --- htmx fragment tests ---
|
||
|
||
test('GET /fragments/nav returns HTML folder-note tree', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [
|
||
{ id: 'f1', title: 'Folder 1', parentId: '', createdTime: 0, updatedTime: 0 },
|
||
],
|
||
noteHeadersByUserId: async () => [
|
||
{ id: 'n1', title: 'Note 1', parentId: 'f1', updatedTime: 0 },
|
||
],
|
||
searchNotes: async () => [
|
||
{ id: 'n1', title: 'Note 1', parentId: 'f1', updatedTime: 0 },
|
||
],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/nav?q=Note' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.headers['content-type'].includes('text/html'));
|
||
assert.ok(res.body.includes('Search Results'));
|
||
assert.ok(!res.body.includes('All Notes'));
|
||
assert.ok(!res.body.includes('Folder 1'));
|
||
assert.ok(res.body.includes('Note 1'));
|
||
assert.ok(res.body.includes('id="nav-search"'));
|
||
assert.ok(res.body.includes('class="nav-search-form"'));
|
||
assert.ok(res.body.includes('🔍'));
|
||
assert.ok(res.body.includes('value="Note"'));
|
||
assert.ok(res.body.includes('hx-get="/fragments/editor/n1?currentFolderId=__search_results__"'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/editor/:id preserves current folder context', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Test Note', body: 'Hello world', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/editor/n1?currentFolderId=__all_notes__' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('name="currentFolderId" value="__all_notes__"'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/editor/:id returns HTML editor', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Test Note', body: 'Hello world', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/editor/n1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
assert.ok(res.body.includes('Test Note'));
|
||
assert.ok(res.body.includes('Hello world'));
|
||
assert.ok(res.body.includes('hx-trigger="joplock:save"'));
|
||
assert.ok(res.body.includes('name="baseUpdatedTime" value="2000"'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id autosaves and returns status', async () => {
|
||
let savedUpdates = null;
|
||
const existing = { id: 'n1', title: 'Old', body: 'Old', parentId: 'f1', createdTime: 1000, updatedTime: 1000 };
|
||
let callCount = 0;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => { callCount += 1; return callCount === 1 ? existing : { ...existing, title: 'Updated Title', body: 'Updated body', updatedTime: 2000 }; },
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async (_sid, _ex, updates) => { savedUpdates = updates; return { id: 'n1' }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Updated+Title&body=Updated+body&baseUpdatedTime=1000¤tFolderId=__all_notes__',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Saved'));
|
||
assert.ok(res.body.includes('id="nav-panel" hx-swap-oob="innerHTML"'), 'should refresh nav panel');
|
||
assert.ok(res.body.includes('id="editor-sync-state" hx-swap-oob="outerHTML"'));
|
||
assert.ok(res.body.includes('name="baseUpdatedTime" value="2000"'));
|
||
// Nav now lazy-loads notes; folder rows are present but note items are fetched on demand
|
||
assert.ok(res.body.includes('Folder 1'));
|
||
assert.equal(savedUpdates.title, 'Updated Title');
|
||
assert.equal(savedUpdates.body, 'Updated body');
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id returns conflict status fragment when note changed remotely', async () => {
|
||
await withServer({
|
||
itemService: { noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Remote', body: 'Remote body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }) },
|
||
itemWriteService: {
|
||
updateNote: async () => { throw new Error('should not save'); },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Updated+Title&body=Updated+body&baseUpdatedTime=1000',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Conflict'));
|
||
assert.ok(res.body.includes('Overwrite'));
|
||
assert.ok(res.body.includes('Create copy'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id can create copy on conflict', async () => {
|
||
let createdArgs = null;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, id) => id === 'n-copy' ? { id: 'n-copy', title: 'Updated Title-3', body: 'Updated body', parentId: 'f1', createdTime: 3000, updatedTime: 3000 } : { id: 'n1', title: 'Remote', body: 'Remote body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 },
|
||
noteHeadersByFolder: async () => [
|
||
{ id: 'n1', title: 'Updated Title', parentId: 'f1', updatedTime: 1000 },
|
||
{ id: 'n2', title: 'Updated Title-1', parentId: 'f1', updatedTime: 1000 },
|
||
{ id: 'n3', title: 'Updated Title-2', parentId: 'f1', updatedTime: 1000 },
|
||
],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
createNote: async (_sid, note) => { createdArgs = note; return { id: 'n-copy' }; },
|
||
updateNote: async () => { throw new Error('should not update'); },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Updated+Title&body=Updated+body&parentId=f1&baseUpdatedTime=1000&createCopy=1',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(createdArgs.title, 'Updated Title-3');
|
||
assert.equal(createdArgs.body, 'Updated body');
|
||
assert.ok(res.body.includes('id="nav-panel" hx-swap-oob="innerHTML"'));
|
||
// Nav lazy-loads notes; folder is present but individual note items are fetched on demand
|
||
assert.ok(res.body.includes('Folder 1'));
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n-copy"'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id rejects plaintext create copy inside vault', async () => {
|
||
let createdArgs = null;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Remote', body: 'Remote body', parentId: 'vault-1', createdTime: 1000, updatedTime: 2000 }),
|
||
noteHeadersByFolder: async () => [],
|
||
},
|
||
itemWriteService: {
|
||
createNote: async (_sid, note) => { createdArgs = note; return { id: 'n-copy' }; },
|
||
updateNote: async () => { throw new Error('should not update'); },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Updated+Title&body=Updated+body&parentId=vault-1&baseUpdatedTime=1000&createCopy=1',
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.equal(createdArgs, null);
|
||
assert.ok(res.body.includes('Vault notes must be saved encrypted'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/folders creates folder and returns list', async () => {
|
||
let created = false;
|
||
await withServer({
|
||
itemWriteService: {
|
||
createFolder: async () => { created = true; return { id: 'f-new' }; },
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f-new', title: 'New Folder', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/folders',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=New+Folder',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(created);
|
||
assert.ok(res.body.includes('New Folder'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/folders/:id renames folder and returns list', async () => {
|
||
let updated = false;
|
||
await withServer({
|
||
itemWriteService: {
|
||
updateFolder: async () => { updated = true; return { id: 'f1' }; },
|
||
},
|
||
itemService: {
|
||
folderByUserIdAndJopId: async () => ({ id: 'f1', title: 'Old Folder', parentId: '' }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Renamed Folder', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/folders/f1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Renamed+Folder',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(updated);
|
||
assert.ok(res.body.includes('Renamed Folder'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes selects created note and loads editor', async () => {
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async () => ({ id: 'n-new' }),
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, id) => ({ id, title: 'Untitled note', body: '', parentId: 'f1', updatedTime: Date.now() }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'parentId=f1',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
// Nav lazy-loads notes; check folder is present and editor loaded for new note
|
||
assert.ok(res.body.includes('Folder 1'));
|
||
assert.ok(res.body.includes('id="editor-panel" hx-swap-oob="innerHTML"'));
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n-new"'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes saves plain title on create', async () => {
|
||
let createdNote = null;
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async (_sid, note) => {
|
||
createdNote = note;
|
||
return { id: 'n-new' };
|
||
},
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, id) => ({ id, title: 'Hello world', body: '', parentId: 'f1', updatedTime: Date.now() }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'parentId=f1&title=%23+**Hello**+%5Bworld%5D(https%3A%2F%2Fexample.com)',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(createdNote.title, 'Hello world');
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes in vault renders locked editor with vault id', async () => {
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async () => ({ id: 'n-new' }),
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, id) => ({ id, title: 'Untitled note', body: '', parentId: 'vault-1', updatedTime: Date.now() }),
|
||
foldersByUserId: async () => [{ id: 'vault-1', title: 'Vault 1', parentId: '' }],
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'parentId=vault-1',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('data-vault-id="vault-1"'));
|
||
assert.ok(res.body.includes('id="editor-locked"'));
|
||
assert.ok(res.body.includes('id="editor-toolbar" style="display:none"'));
|
||
assert.ok(res.body.includes('id="cm-host" style="display:none"'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes falls back to created note when immediate read misses', async () => {
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async () => ({ id: 'n-new' }),
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => null,
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'parentId=f1',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n-new"'));
|
||
assert.ok(res.body.includes('value="f1" selected'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes/in-general falls back to created note when immediate read misses', async () => {
|
||
await withServer({
|
||
itemWriteService: {
|
||
createNote: async () => ({ id: 'n-new' }),
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => null,
|
||
foldersByUserId: async () => [{ id: 'general', title: 'General', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes/in-general',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: '',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n-new"'));
|
||
assert.ok(res.body.includes('value="general" selected'));
|
||
});
|
||
});
|
||
|
||
test('DELETE /fragments/notes/:id trashes note and shows trash folder', async () => {
|
||
let trashed = false;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Note 1', body: 'body', parentId: 'f1', createdTime: 1000, updatedTime: 1000, deletedTime: 0 }),
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [{ id: 'n1', title: 'Note 1', parentId: 'f1', updatedTime: 1000, deletedTime: 2000 }] : [],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
trashNote: async () => { trashed = true; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes/n1',
|
||
method: 'DELETE',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(trashed);
|
||
assert.ok(res.body.includes('Trash'));
|
||
assert.ok(res.body.includes('id="editor-panel" hx-swap-oob="innerHTML"'));
|
||
});
|
||
});
|
||
|
||
test('DELETE /fragments/notes/:id permanently deletes trashed note', async () => {
|
||
let deleted = false;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, _id, options = {}) => options.deleted === 'only' ? { id: 'n1', title: 'Note 1', body: 'body', parentId: 'f1', createdTime: 1000, updatedTime: 1000, deletedTime: 2000 } : null,
|
||
noteHeadersByUserId: async () => [],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
deleteNote: async () => { deleted = true; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes/n1',
|
||
method: 'DELETE',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(deleted);
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/notes/:id/restore restores trashed note', async () => {
|
||
let restoreArgs = null;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async (_uid, id, options = {}) => {
|
||
if (options.deleted === 'only') return { id, title: 'Deleted Note', body: 'body', parentId: 'f2', createdTime: 1000, updatedTime: 2000, deletedTime: 2000 };
|
||
return { id, title: 'Deleted Note', body: 'body', parentId: 'f2', createdTime: 1000, updatedTime: 3000, deletedTime: 0 };
|
||
},
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [] : [{ id: 'n1', title: 'Deleted Note', parentId: 'f2', updatedTime: 3000, deletedTime: 0 }],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }, { id: 'f2', title: 'Folder 2', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
restoreNote: async (_sid, note, parentId) => { restoreArgs = { note, parentId }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/notes/n1/restore',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(restoreArgs.parentId, 'f2');
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/trash/empty permanently deletes trashed notes', async () => {
|
||
const deletedIds = [];
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [{ id: 'n1', title: 'Deleted Note', parentId: 'f1', updatedTime: 1000, deletedTime: 2000 }] : [],
|
||
foldersByUserId: async () => [],
|
||
},
|
||
itemWriteService: {
|
||
deleteNote: async (_sid, id) => { deletedIds.push(id); },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/trash/empty',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.deepEqual(deletedIds, ['n1']);
|
||
});
|
||
});
|
||
|
||
test('GET / returns full SSR page for logged-in user', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'My Folder', parentId: '' }],
|
||
noteHeadersByUserId: async () => [],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('<!DOCTYPE html>'));
|
||
assert.ok(res.body.includes('Joplock'));
|
||
assert.ok(res.body.includes('My Folder'));
|
||
assert.ok(res.body.includes('Trash'));
|
||
assert.ok(res.body.includes('htmx.min.js'));
|
||
assert.ok(res.body.includes('apple-touch-icon.png'));
|
||
assert.ok(res.body.includes('apple-touch-startup-image'));
|
||
});
|
||
});
|
||
|
||
test('GET / resumes last edited note from server-side settings when enabled', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, noteOpenMode: 'preview', resumeLastNote: true, lastNoteId: 'n1', lastNoteFolderId: '__all_notes__', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'My Folder', parentId: '' }],
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: '# **Hello**', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 1000, deletedTime: 0 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
// Nav lazy-loads notes; folder is present but note items are fetched on demand
|
||
assert.ok(res.body.includes('My Folder'));
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
assert.ok(res.body.includes('data-placeholder="Note title">Hello</div>'));
|
||
assert.ok(!res.body.includes('<strong>Hello</strong>'));
|
||
});
|
||
});
|
||
|
||
test('GET / includes mobile startup resume payload for resumed note', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, noteOpenMode: 'preview', resumeLastNote: true, lastNoteId: 'n1', lastNoteFolderId: '__all_notes__', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'My Folder', parentId: '' }],
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [] : [{ id: 'n1', title: '# **Hello**', parentId: 'f1', updatedTime: 1000, deletedTime: 0 }],
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: '# **Hello**', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 1000, deletedTime: 0 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('mobileStartup:{"folderId":"f1","folderTitle":"My Folder","noteId":"n1","noteTitle":"Hello"}'));
|
||
assert.ok(res.body.includes('<div class="mobile-screen-body mobile-editor-body" id="mobile-editor-body">'));
|
||
assert.ok(res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
assert.ok(!res.body.includes('<div class="mobile-screen-body mobile-editor-body" id="mobile-editor-body">\n\t\t\t\t<div class="editor-empty">Select a note</div>'));
|
||
});
|
||
});
|
||
|
||
test('GET / does not resume encrypted last note on startup', async () => {
|
||
let savedSettings = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, noteOpenMode: 'preview', resumeLastNote: true, lastNoteId: 'n1', lastNoteFolderId: 'f1', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => { savedSettings = settings; return settings; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'My Folder', parentId: '' }],
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Secret', body: 'cipher', parentId: 'f1', createdTime: 1000, updatedTime: 1000, deletedTime: 0, isEncrypted: true }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(!res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
assert.ok(res.body.includes('<div class="editor-empty">Select a note</div>'));
|
||
assert.equal(savedSettings.lastNoteId, '');
|
||
assert.equal(savedSettings.lastNoteFolderId, '');
|
||
});
|
||
});
|
||
|
||
test('GET / does not resume last note inside vault notebook on startup', async () => {
|
||
let savedSettings = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, noteOpenMode: 'preview', resumeLastNote: true, lastNoteId: 'n1', lastNoteFolderId: 'vault-1', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => { savedSettings = settings; return settings; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'vault-1', title: 'Vault 1', parentId: '', isVault: true }],
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Secret', body: '', parentId: 'vault-1', createdTime: 1000, updatedTime: 1000, deletedTime: 0, isEncrypted: false }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(!res.body.includes('hx-put="/fragments/editor/n1"'));
|
||
assert.ok(!res.body.includes('mobileStartup:{"folderId":"vault-1"'));
|
||
assert.equal(savedSettings.lastNoteId, '');
|
||
assert.equal(savedSettings.lastNoteFolderId, '');
|
||
});
|
||
});
|
||
|
||
test('GET / creates starter content when user has no real folders', async () => {
|
||
let folderCreates = 0;
|
||
let noteCreates = 0;
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => folderCreates > 0 ? [{ id: 'examples', title: 'Examples', parentId: '' }] : [],
|
||
noteHeadersByUserId: async () => [],
|
||
},
|
||
itemWriteService: {
|
||
createFolder: async () => { folderCreates += 1; return { id: 'examples' }; },
|
||
createNote: async () => { noteCreates += 1; return { id: 'start-here' }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(folderCreates, 1);
|
||
assert.equal(noteCreates, 1);
|
||
assert.ok(res.body.includes('Examples'));
|
||
});
|
||
});
|
||
|
||
test('GET / redirects unauthenticated user to /login', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/', headers: {} });
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(res.headers.location, '/login');
|
||
});
|
||
});
|
||
|
||
test('GET /login returns login page for unauthenticated user', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/login', headers: {} });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Login'));
|
||
assert.ok(!res.body.includes('Authentication code'));
|
||
assert.ok(!res.body.includes('NOTEBOOKS'));
|
||
});
|
||
});
|
||
|
||
test('POST /login skips per-user MFA for admin when IGNORE_ADMIN_MFA is set', async () => {
|
||
await withServer({
|
||
adminEmail: 'admin@example.com',
|
||
ignoreAdminMfa: true,
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_userId, s) => s,
|
||
getTotpSeed: async () => 'TESTSEEDNEVERUSED',
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'admin-sess') return { id: 'admin-1', email: 'admin@example.com', sessionId };
|
||
return null;
|
||
},
|
||
},
|
||
}, async port => {
|
||
// Mock Joplin Server login succeeds by relying on test mock
|
||
// The test verifies the MFA bypass path; Joplin login is mocked upstream
|
||
const res = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'email=admin%40example.com&password=test',
|
||
});
|
||
// Without the bypass, this would redirect to /login/mfa since getTotpSeed returns a seed
|
||
// With bypass, it should either set session or fail at Joplin login (302 to / or /login?error=)
|
||
assert.equal(res.statusCode, 302);
|
||
// Should NOT redirect to MFA page
|
||
assert.ok(!res.headers.location.includes('/login/mfa'));
|
||
});
|
||
});
|
||
|
||
test('POST /login creates starter content for user with no real folders', async () => {
|
||
const upstream = http.createServer((req, res) => {
|
||
if (req.method === 'POST' && req.url === '/api/sessions') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ id: 'fresh-session' }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'not found' }));
|
||
});
|
||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||
const upstreamPort = upstream.address().port;
|
||
let folderCreates = 0;
|
||
let noteCreates = 0;
|
||
try {
|
||
await withServer({
|
||
joplinServerOrigin: `http://127.0.0.1:${upstreamPort}`,
|
||
joplinServerPublicUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'test-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
if (sessionId === 'fresh-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
return null;
|
||
},
|
||
},
|
||
itemService: {
|
||
foldersByUserId: async () => folderCreates > 0 ? [{ id: 'examples', title: 'Examples', parentId: '' }] : [],
|
||
noteHeadersByUserId: async () => [],
|
||
},
|
||
itemWriteService: {
|
||
createFolder: async () => { folderCreates += 1; return { id: 'examples' }; },
|
||
createNote: async () => { noteCreates += 1; return { id: 'start-here' }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'email=user%40example.com&password=UserPass123%21',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(res.headers.location, '/');
|
||
const setCookie = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'].join('; ') : res.headers['set-cookie'];
|
||
assert.ok(setCookie.includes('sessionId=fresh-session'));
|
||
assert.ok(setCookie.includes('Max-Age=31536000'));
|
||
assert.equal(folderCreates, 1);
|
||
assert.equal(noteCreates, 1);
|
||
});
|
||
} finally {
|
||
await new Promise(resolve => upstream.close(resolve));
|
||
}
|
||
});
|
||
|
||
test('POST /login sets sessionId cookie with custom Max-Age when sessionCookieMaxAge is provided', async () => {
|
||
const upstream = http.createServer((req, res) => {
|
||
if (req.method === 'POST' && req.url === '/api/sessions') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ id: 'fresh-session' }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'not found' }));
|
||
});
|
||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||
const upstreamPort = upstream.address().port;
|
||
try {
|
||
await withServer({
|
||
joplinServerOrigin: `http://127.0.0.1:${upstreamPort}`,
|
||
joplinServerPublicUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
sessionCookieMaxAge: 86400,
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'fresh-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
return null;
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'email=user%40example.com&password=test',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
const setCookie = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'].join('; ') : res.headers['set-cookie'];
|
||
assert.ok(setCookie.includes('sessionId=fresh-session'));
|
||
assert.ok(setCookie.includes('Max-Age=86400'));
|
||
});
|
||
} finally {
|
||
await new Promise(resolve => upstream.close(resolve));
|
||
}
|
||
});
|
||
|
||
test('POST /login returns 429 after repeated failed attempts', async () => {
|
||
const upstream = http.createServer((req, res) => {
|
||
if (req.method === 'POST' && req.url === '/api/sessions') {
|
||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'invalid' }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'not found' }));
|
||
});
|
||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||
const upstreamPort = upstream.address().port;
|
||
try {
|
||
await withServer({
|
||
joplinServerOrigin: `http://127.0.0.1:${upstreamPort}`,
|
||
joplinServerPublicUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
rateLimitService: createRateLimitService(),
|
||
settingsService: {
|
||
appSettings: async () => ({ authRateLimitAttempts: 3 }),
|
||
},
|
||
}, async port => {
|
||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||
const res = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.10' },
|
||
body: 'email=user%40example.com&password=badpass',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
}
|
||
|
||
const blocked = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.10' },
|
||
body: 'email=user%40example.com&password=badpass',
|
||
});
|
||
assert.equal(blocked.statusCode, 429);
|
||
assert.equal(blocked.headers['retry-after'], '900');
|
||
assert.ok(blocked.body.includes('Too many login attempts'));
|
||
});
|
||
} finally {
|
||
await new Promise(resolve => upstream.close(resolve));
|
||
}
|
||
});
|
||
|
||
test('POST /login clears account failure streak after successful login', async () => {
|
||
let loginAttempts = 0;
|
||
const upstream = http.createServer((req, res) => {
|
||
if (req.method === 'POST' && req.url === '/api/sessions') {
|
||
loginAttempts += 1;
|
||
if (loginAttempts === 2) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ id: 'fresh-session' }));
|
||
return;
|
||
}
|
||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'invalid' }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'not found' }));
|
||
});
|
||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||
const upstreamPort = upstream.address().port;
|
||
try {
|
||
await withServer({
|
||
joplinServerOrigin: `http://127.0.0.1:${upstreamPort}`,
|
||
joplinServerPublicUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
rateLimitService: createRateLimitService(),
|
||
settingsService: {
|
||
appSettings: async () => ({ authRateLimitAttempts: 2 }),
|
||
},
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'test-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
if (sessionId === 'fresh-session') return { id: 'user-1', email: 'user@example.com', sessionId };
|
||
return null;
|
||
},
|
||
},
|
||
}, async port => {
|
||
const firstFailure = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.20' },
|
||
body: 'email=user%40example.com&password=badpass',
|
||
});
|
||
assert.equal(firstFailure.statusCode, 302);
|
||
|
||
const success = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.20' },
|
||
body: 'email=user%40example.com&password=goodpass',
|
||
});
|
||
assert.equal(success.statusCode, 302);
|
||
assert.equal(success.headers.location, '/');
|
||
|
||
const secondFailure = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.20' },
|
||
body: 'email=user%40example.com&password=badpass-again',
|
||
});
|
||
assert.equal(secondFailure.statusCode, 302);
|
||
|
||
const thirdFailure = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.20' },
|
||
body: 'email=user%40example.com&password=badpass-final',
|
||
});
|
||
assert.equal(thirdFailure.statusCode, 302);
|
||
|
||
const blocked = await request(port, {
|
||
path: '/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.20' },
|
||
body: 'email=user%40example.com&password=badpass-blocked',
|
||
});
|
||
assert.equal(blocked.statusCode, 429);
|
||
});
|
||
} finally {
|
||
await new Promise(resolve => upstream.close(resolve));
|
||
}
|
||
});
|
||
|
||
test('POST /login/mfa returns 429 after repeated invalid codes', async () => {
|
||
await withServer({
|
||
rateLimitService: createRateLimitService(),
|
||
sessionService: {
|
||
userBySessionId: async sessionId => sessionId === 'pending-1' ? { id: 'user-1', email: 'user@example.com', sessionId } : null,
|
||
},
|
||
settingsService: {
|
||
appSettings: async () => ({ authRateLimitAttempts: 2 }),
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
getTotpSeed: async () => 'JBSWY3DPEHPK3PXP',
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
const res = await request(port, {
|
||
path: '/login/mfa',
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Cookie: 'pendingSession=pending-1',
|
||
'X-Forwarded-For': '198.51.100.30',
|
||
},
|
||
body: 'totp=000000',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(res.headers.location, '/login/mfa?error=Invalid+code');
|
||
}
|
||
|
||
const blocked = await request(port, {
|
||
path: '/login/mfa',
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Cookie: 'pendingSession=pending-1',
|
||
'X-Forwarded-For': '198.51.100.30',
|
||
},
|
||
body: 'totp=000000',
|
||
});
|
||
assert.equal(blocked.statusCode, 429);
|
||
assert.equal(blocked.headers['retry-after'], '900');
|
||
assert.ok(blocked.body.includes('Too many authentication attempts'));
|
||
});
|
||
});
|
||
|
||
test('GET /settings redirects unauthenticated user to login', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/settings', headers: {} });
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(res.headers.location, '/login');
|
||
});
|
||
});
|
||
|
||
test('GET /settings shows font controls and per-user MFA section', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 17, codeFontSize: 13, noteMonospace: true, resumeLastNote: true, dateFormat: 'DD/MM/YYYY', datetimeFormat: 'DD/MM/YYYY HH:mm' }),
|
||
getTotpSeed: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/settings' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Joplock Settings'));
|
||
assert.ok(res.body.includes('settings-note-font'));
|
||
assert.ok(res.body.includes('value="17"'));
|
||
assert.ok(res.body.includes('Use monospace for note text'));
|
||
assert.ok(res.body.includes('Reopen the last edited note on startup'));
|
||
assert.ok(res.body.includes('Two-Factor Authentication'));
|
||
});
|
||
});
|
||
|
||
test('PUT /api/web/settings saves individual settings', async () => {
|
||
let saved = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm', autoLogout: false, autoLogoutMinutes: 15, theme: 'matrix' }),
|
||
saveSettings: async (_userId, settings) => { saved = settings; return settings; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/settings',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'theme=nord',
|
||
});
|
||
assert.equal(res.statusCode, 204);
|
||
assert.equal(saved.theme, 'nord');
|
||
assert.equal(saved.noteFontSize, 15); // unchanged
|
||
});
|
||
});
|
||
|
||
test('POST /api/web/client-log accepts authenticated client diagnostics', async () => {
|
||
const originalInfo = console.info;
|
||
let logged = null;
|
||
console.info = (...args) => { logged = args; };
|
||
try {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/client-log',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'event=expander.detect&data=' + encodeURIComponent(JSON.stringify({ trigger: ';sig', text: 'secret', ok: true })),
|
||
});
|
||
assert.equal(res.statusCode, 204);
|
||
});
|
||
assert.equal(logged[0], '[joplock client]');
|
||
assert.equal(logged[1], 'expander.detect');
|
||
assert.equal(logged[2].trigger, ';sig');
|
||
assert.equal(logged[2].ok, true);
|
||
assert.equal(logged[2].text, undefined);
|
||
} finally {
|
||
console.info = originalInfo;
|
||
}
|
||
});
|
||
|
||
test('PUT /api/web/settings accepts resumeLastNote preference', async () => {
|
||
let saved = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: 'n1', lastNoteFolderId: 'f1', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm', autoLogout: false, autoLogoutMinutes: 15, theme: 'matrix' }),
|
||
saveSettings: async (_userId, settings) => { saved = settings; return settings; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/settings',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'resumeLastNote=1',
|
||
});
|
||
assert.equal(res.statusCode, 204);
|
||
assert.equal(saved.resumeLastNote, '1');
|
||
assert.equal(saved.lastNoteId, 'n1');
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/editor/:id stores last note state on the server', async () => {
|
||
let savedSettings = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: true, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => { savedSettings = settings; return settings; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: '# Title', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/editor/n1?currentFolderId=__all_notes__' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(savedSettings.lastNoteId, 'n1');
|
||
assert.equal(savedSettings.lastNoteFolderId, '__all_notes__');
|
||
assert.ok(res.body.includes('data-placeholder="Note title">Title</div>'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id saves plain title and last note state on the server', async () => {
|
||
let savedUpdates = null;
|
||
let savedSettings = null;
|
||
const existing = { id: 'n1', title: 'Old', body: 'Old', parentId: 'f1', createdTime: 1000, updatedTime: 1000 };
|
||
let callCount = 0;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: true, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
saveSettings: async (_userId, settings) => { savedSettings = settings; return settings; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => { callCount += 1; return callCount === 1 ? existing : { ...existing, title: 'Hello world', body: 'Updated body', updatedTime: 2000 }; },
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [] : [{ id: 'n1', title: 'Hello world', parentId: 'f1', updatedTime: 2000, deletedTime: 0 }],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder 1', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async (_sid, _ex, updates) => { savedUpdates = updates; return { id: 'n1' }; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=%23+**Hello**+%5Bworld%5D(https%3A%2F%2Fexample.com)&body=Updated+body&baseUpdatedTime=1000¤tFolderId=__all_notes__',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(savedUpdates.title, 'Hello world');
|
||
assert.equal(savedSettings.lastNoteId, 'n1');
|
||
assert.equal(savedSettings.lastNoteFolderId, '__all_notes__');
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/search returns matching notes', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async (_uid, query) => {
|
||
if (query === 'hello') {
|
||
return [{ id: 'n1', title: 'Hello World', body: 'content', bodyPreview: 'content', parentId: 'f1' }];
|
||
}
|
||
return [];
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/search?q=hello' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Hello World'));
|
||
assert.ok(res.body.includes('hx-get="/fragments/editor/n1?currentFolderId=search"'));
|
||
|
||
const empty = await request(port, { path: '/fragments/search?q=' });
|
||
assert.equal(empty.statusCode, 200);
|
||
assert.equal(empty.body, '');
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/search shows lock icon for notes inside vault notebooks', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async () => [{ id: 'n1', title: 'Vault Note', body: 'content', bodyPreview: 'content', parentId: 'vault-1' }],
|
||
},
|
||
vaultService: {
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
getVaultByFolderId: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/search?q=vault' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('note-lock-icon'));
|
||
assert.ok(res.body.includes('data-vault-id="vault-1"'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/search shows Load more when exactly 50 results returned', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async (_uid, _query, limit, offset) => {
|
||
assert.equal(limit, 50);
|
||
assert.equal(offset, 0);
|
||
return Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, title: `Note ${i}`, body: '', bodyPreview: '', parentId: 'f1' }));
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/search?q=test' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('notelist-load-more'), 'should include Load more button');
|
||
assert.ok(res.body.includes('/fragments/search?q=test&offset=50'), 'Load more URL should have offset=50');
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor rejects plaintext save for note already inside vault', async () => {
|
||
let updateCalled = false;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Secret', body: 'plain body', parentId: 'vault-1', updatedTime: 1000 }),
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async () => { updateCalled = true; return { id: 'n1' }; },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Secret&body=plain%20body&parentId=vault-1¤tFolderId=vault-1&baseUpdatedTime=1000',
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.ok(res.body.includes('Vault notes must be saved encrypted'));
|
||
assert.equal(updateCalled, false);
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor rejects plaintext save when moving note into vault', async () => {
|
||
let updateCalled = false;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Draft', body: 'plain body', parentId: 'f1', updatedTime: 1000 }),
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async () => { updateCalled = true; return { id: 'n1' }; },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Draft&body=plain%20body&parentId=vault-1¤tFolderId=f1&baseUpdatedTime=1000',
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.ok(res.body.includes('Vault notes must be saved encrypted'));
|
||
assert.equal(updateCalled, false);
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/search passes offset to searchNotes', async () => {
|
||
let receivedOffset;
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async (_uid, _query, _limit, offset) => {
|
||
receivedOffset = offset;
|
||
return [{ id: 'n1', title: 'Note', body: '', bodyPreview: '', parentId: 'f1' }];
|
||
},
|
||
},
|
||
}, async port => {
|
||
await request(port, { path: '/fragments/search?q=test&offset=50' });
|
||
assert.equal(receivedOffset, 50);
|
||
});
|
||
});
|
||
|
||
// --- Resource tests ---
|
||
|
||
test('GET /resources/:id serves binary blob with correct content-type', async () => {
|
||
const blobData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); // fake PNG header
|
||
await withServer({
|
||
itemService: {
|
||
resourceMetaByUserId: async (_uid, rid) => {
|
||
if (rid === 'abcdef01234567890abcdef012345678') return { id: rid, mime: 'image/png', title: 'test.png', filename: 'test.png' };
|
||
return null;
|
||
},
|
||
resourceBlobByUserId: async (_uid, rid) => {
|
||
if (rid === 'abcdef01234567890abcdef012345678') return blobData;
|
||
return null;
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/resources/abcdef01234567890abcdef012345678' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['cache-control'], 'no-store');
|
||
assert.equal(res.headers['content-type'], 'image/png');
|
||
assert.equal(res.headers['content-disposition'], 'inline; filename="test.png"');
|
||
assert.ok(res.rawBody.equals(blobData));
|
||
});
|
||
});
|
||
|
||
test('GET /resources/:id forces attachment download when requested', async () => {
|
||
const blobData = Buffer.from('%PDF-1.7');
|
||
await withServer({
|
||
itemService: {
|
||
resourceMetaByUserId: async (_uid, rid) => rid === 'abcdef01234567890abcdef012345678' ? { id: rid, mime: 'application/pdf', title: 'manual.pdf', filename: 'manual.pdf' } : null,
|
||
resourceBlobByUserId: async (_uid, rid) => rid === 'abcdef01234567890abcdef012345678' ? blobData : null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/resources/abcdef01234567890abcdef012345678?download=1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['content-disposition'], 'attachment; filename="manual.pdf"');
|
||
assert.ok(res.rawBody.equals(blobData));
|
||
});
|
||
});
|
||
|
||
test('GET /resources/:id viewer renders html page with back control', async () => {
|
||
const blobData = Buffer.from('%PDF-1.7');
|
||
await withServer({
|
||
itemService: {
|
||
resourceMetaByUserId: async (_uid, rid) => rid === 'abcdef01234567890abcdef012345678' ? { id: rid, mime: 'application/pdf', title: 'manual.pdf', filename: 'manual.pdf' } : null,
|
||
resourceBlobByUserId: async (_uid, rid) => rid === 'abcdef01234567890abcdef012345678' ? blobData : null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/resources/abcdef01234567890abcdef012345678?viewer=1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok((res.headers['content-type'] || '').includes('text/html'));
|
||
assert.ok(res.body.includes('resource-viewer-page-btn'));
|
||
assert.ok(res.body.includes('history.length > 1 ? history.back() : window.close()'));
|
||
assert.ok(res.body.includes('/resources/abcdef01234567890abcdef012345678?download=1'));
|
||
});
|
||
});
|
||
|
||
test('HEAD /resources/:id returns resource headers without body', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
resourceMetaByUserId: async (_uid, rid) => rid === 'abcdef01234567890abcdef012345678' ? { id: rid, mime: 'application/octet-stream', title: 'key.pub', filename: 'key.pub' } : null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/resources/abcdef01234567890abcdef012345678', method: 'HEAD' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['content-type'], 'application/octet-stream');
|
||
assert.equal(res.headers['content-disposition'], 'attachment; filename="key.pub"');
|
||
assert.equal(res.rawBody.length, 0);
|
||
});
|
||
});
|
||
|
||
test('GET /api/web/notes returns all notes for virtual all notes folder', async () => {
|
||
let receivedOptions = null;
|
||
await withServer({
|
||
itemService: {
|
||
notesByUserId: async (_uid, options = {}) => {
|
||
receivedOptions = options;
|
||
return [{ id: 'n1', title: 'Note 1', parentId: 'f1' }];
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/notes?folderId=__all_notes__' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.deepEqual(receivedOptions, {});
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.items.length, 1);
|
||
assert.equal(payload.items[0].id, 'n1');
|
||
});
|
||
});
|
||
|
||
test('GET /api/web/notes/headers returns minimal note headers', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByUserId: async (_uid) => {
|
||
return [
|
||
{ id: 'n1', parentId: 'f1', title: 'Note 1', isEncrypted: false, deletedTime: 0, updatedTime: 123456 },
|
||
{ id: 'n2', parentId: 'f1', title: 'Note 2', isEncrypted: true, deletedTime: 0, updatedTime: 123457 }
|
||
];
|
||
},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/notes/headers' });
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.deepEqual(payload.items, [
|
||
{ id: 'n1', title: 'Note 1' },
|
||
{ id: 'n2', title: 'Note 2' }
|
||
]);
|
||
});
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete returns OpenRouter prose text', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedUrl = '';
|
||
let receivedBody = '';
|
||
global.fetch = async (url, options = {}) => {
|
||
receivedUrl = `${url}`;
|
||
receivedBody = `${options.body || ''}`;
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'the sky was a deep blue' } }] }),
|
||
text: async () => '',
|
||
};
|
||
};
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({
|
||
openRouterApiKey: 'sk-or-v1-test',
|
||
openRouterModel: 'openai/gpt-4o-mini',
|
||
proseAutocompleteSentenceCount: 3,
|
||
aiProfiles: [{ id: 'p-openrouter', name: 'OpenRouter', providerId: 'openrouter', apiKey: 'sk-or-v1-test', model: 'openai/gpt-4o-mini', temperature: 0.2, active: true }],
|
||
}),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=Write%20a%20calm%20opening',
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.text, 'the sky was a deep blue.');
|
||
assert.equal(typeof payload.contextChars, 'number');
|
||
});
|
||
assert.equal(receivedUrl, 'https://openrouter.ai/api/v1/chat/completions');
|
||
assert.ok(receivedBody.includes('openai/gpt-4o-mini'));
|
||
assert.ok(receivedBody.includes('"frequency_penalty":0.4'));
|
||
assert.ok(receivedBody.includes('"presence_penalty":0.2'));
|
||
assert.ok(receivedBody.includes('same kind of document already being written'));
|
||
assert.ok(receivedBody.includes('If the note reads like a story, continue the story'));
|
||
assert.ok(receivedBody.includes('Write exactly 3 complete sentences total'));
|
||
assert.ok(receivedBody.includes('count it as the first sentence'));
|
||
assert.ok(receivedBody.includes('Stop immediately after the sentences'));
|
||
assert.ok(receivedBody.includes('Return only the new text to append after the current final character'));
|
||
assert.equal(JSON.parse(receivedBody).max_tokens, 96);
|
||
assert.equal(JSON.parse(receivedBody).temperature, 0.2);
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims repeated prompt suffix from prose text', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'It is very popular among enthusiasts for its unique blend of historical charm.' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('It is very popular'),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'among enthusiasts for its unique blend of historical charm.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims repeated prompt suffix with smart punctuation', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'It’s also essential that we integrate robust security measures from the outset.' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent("It's also"),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'essential that we integrate robust security measures from the outset.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims repeated prompt suffix when prompt ends with nbsp entity', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'EFS can also integrate with Docker workloads to provide scalable shared file storage that supports concurrent access from multiple containers across availability zones.' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('Amazon EFS is shared storage for containers. EFS can also '),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'integrate with Docker workloads to provide scalable shared file storage that supports concurrent access from multiple containers across availability zones.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims duplicated raw prompt prefix from prose text', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'You belong to me now. Her eyes widened with a potent mix of shock and dark excitement as my declaration hung heavy in the charged air.' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 2 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('You belong '),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'to me now. Her eyes widened with a potent mix of shock and dark excitement as my declaration hung heavy in the charged air.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims incomplete trailing fragment to a complete sentence', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'to me now. Her eyes widened with a potent mix of shock and dark excitement as my declaration hung heavy in the charged air before' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 2 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('You belong '),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'to me now.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete trims over-generated text to configured sentence count', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedBody = '';
|
||
global.fetch = async (_url, options = {}) => {
|
||
receivedBody = `${options.body || ''}`;
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'It should use the smallest practical response. Extra details should not be included. This sentence should be trimmed.' } }] }),
|
||
text: async () => '',
|
||
};
|
||
};
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('Please be concise.'),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'It should use the smallest practical response.');
|
||
});
|
||
assert.equal(JSON.parse(receivedBody).max_tokens, 32);
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete reports empty provider output', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: '' }, finish_reason: 'stop' }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('This note has enough context for autocomplete.'),
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.text, '');
|
||
assert.equal(payload.emptyReason, 'provider-empty');
|
||
assert.equal(payload.rawChars, 0);
|
||
assert.equal(payload.finishReason, 'stop');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete reports repeated existing text output', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'This note has enough context for autocomplete.' }, finish_reason: 'stop' }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const prompt = 'This note has enough context for autocomplete.';
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent(prompt),
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.text, '');
|
||
assert.equal(payload.emptyReason, 'provider-repeated-existing-text');
|
||
assert.equal(payload.rawChars, prompt.length);
|
||
assert.equal(payload.suffixTrimmedChars, 0);
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete collapses adjacent repeated phrases', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'Her moaning became so loud that it echoed through the room, causing me to fear causing me to fear that the rest of the family might hear us from downstairs.' } }] }),
|
||
text: async () => '',
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent('Her moaning became so loud that it echoed through the room,'),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(JSON.parse(res.body).text, 'causing me to fear that the rest of the family might hear us from downstairs.');
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete uses hash-bang note instructions as the role block only', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedBody = '';
|
||
global.fetch = async (_url, options = {}) => {
|
||
receivedBody = `${options.body || ''}`;
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'and unusually quiet.' } }] }),
|
||
text: async () => '',
|
||
};
|
||
};
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const prompt = 'AWS\n#! technical document\nS3 bucket versioning';
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent(prompt),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
});
|
||
const payload = JSON.parse(receivedBody);
|
||
const systemMessages = payload.messages.filter(message => message.role === 'system').map(message => message.content);
|
||
const userMessage = payload.messages.find(message => message.role === 'user').content;
|
||
assert.equal(systemMessages.length, 2);
|
||
assert.equal(systemMessages[0], 'technical document');
|
||
assert.ok(systemMessages[1].includes('Write exactly 1 complete sentence total'));
|
||
assert.ok(systemMessages[1].includes('Do not repeat words or phrases back-to-back'));
|
||
assert.ok(userMessage.includes('S3 bucket versioning'));
|
||
assert.ok(!userMessage.includes('#!'));
|
||
assert.ok(!userMessage.includes('technical document'));
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete preserves multiple hash-bang lines in the role block', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedBody = '';
|
||
global.fetch = async (_url, options = {}) => {
|
||
receivedBody = `${options.body || ''}`;
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'Use Amazon EFS for shared file storage across tasks.' } }] }),
|
||
text: async () => '',
|
||
};
|
||
};
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const prompt = 'AWS\n#! You are a writer of technical documentation.\n#! You specialize in AWS.\nAmazon EFS';
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent(prompt),
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
});
|
||
const payload = JSON.parse(receivedBody);
|
||
const systemMessages = payload.messages.filter(message => message.role === 'system').map(message => message.content);
|
||
const userMessage = payload.messages.find(message => message.role === 'user').content;
|
||
assert.equal(systemMessages[0], 'You are a writer of technical documentation.\nYou specialize in AWS.');
|
||
assert.ok(userMessage.includes('Amazon EFS'));
|
||
assert.ok(!userMessage.includes('#!'));
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/prose-complete strips Joplin attachment references from note body', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedBody = '';
|
||
global.fetch = async (url, opts) => {
|
||
receivedBody = opts && opts.body ? opts.body : '';
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'three.' } }] }),
|
||
};
|
||
};
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: 'sk-or-v1-test', openRouterModel: 'openai/gpt-4o-mini', proseAutocompleteSentenceCount: 1 }),
|
||
},
|
||
}, async port => {
|
||
const prompt = 'Here is my diagram.\n\nAnd here is a PDF.\n[report.pdf](:/deadbeefdeadbeefdeadbeefdeadbeef)\nThe data shows a trend.';
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/prose-complete',
|
||
method: 'POST',
|
||
body: 'prompt=' + encodeURIComponent(prompt),
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
});
|
||
const payload = JSON.parse(receivedBody);
|
||
const userMessage = payload.messages.find(m => m.role === 'user').content;
|
||
assert.ok(!userMessage.includes(':/a1b2c3d4'), 'image resource id should be stripped');
|
||
assert.ok(!userMessage.includes(':/deadbeef'), 'attachment resource id should be stripped');
|
||
assert.ok(!userMessage.includes('diagram.png'), 'image reference should be stripped');
|
||
assert.ok(!userMessage.includes('report.pdf'), 'attachment reference should be stripped');
|
||
assert.ok(userMessage.includes('Here is my diagram.'), 'surrounding text should be preserved');
|
||
assert.ok(userMessage.includes('The data shows a trend.'), 'surrounding text should be preserved');
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/test-profile returns ok when provider responds with three', async () => {
|
||
const originalFetch = global.fetch;
|
||
let receivedBody = '';
|
||
global.fetch = async (url, opts) => {
|
||
receivedBody = opts && opts.body ? opts.body : '';
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'three' } }] }),
|
||
};
|
||
};
|
||
try {
|
||
const { aiProfiles } = require('../app/settingsService').defaultAiProfiles ? { aiProfiles: require('../app/settingsService').defaultAiProfiles.map((p, i) => ({ ...p, apiKey: i === 0 ? 'sk-or-v1-test' : '', active: i === 0 })) } : { aiProfiles: null };
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({
|
||
openRouterApiKey: 'sk-or-v1-test',
|
||
openRouterModel: 'openai/gpt-4o-mini',
|
||
aiProfiles,
|
||
}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/test-profile',
|
||
method: 'POST',
|
||
body: 'profileId=openrouter',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const data = JSON.parse(res.body);
|
||
assert.equal(data.ok, true);
|
||
assert.ok(data.response.toLowerCase().includes('three'));
|
||
assert.ok(typeof data.ms === 'number');
|
||
});
|
||
const payload = JSON.parse(receivedBody);
|
||
assert.equal(payload.max_tokens, 16);
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/test-profile returns ok=false when provider does not respond with three', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ choices: [{ message: { content: 'banana' } }] }),
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({
|
||
openRouterApiKey: 'sk-or-v1-test',
|
||
openRouterModel: 'openai/gpt-4o-mini',
|
||
}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/test-profile',
|
||
method: 'POST',
|
||
body: 'profileId=openrouter',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const data = JSON.parse(res.body);
|
||
assert.equal(data.ok, false);
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/test-profile returns provider error details', async () => {
|
||
const originalFetch = global.fetch;
|
||
global.fetch = async () => ({
|
||
ok: false,
|
||
status: 400,
|
||
text: async () => JSON.stringify({ error: { message: 'No endpoints found matching your data policy' } }),
|
||
});
|
||
try {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({
|
||
openRouterApiKey: 'sk-or-v1-test',
|
||
openRouterModel: 'openai/gpt-5.4-mini',
|
||
}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/test-profile',
|
||
method: 'POST',
|
||
body: 'profileId=openrouter',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const data = JSON.parse(res.body);
|
||
assert.equal(data.ok, false);
|
||
assert.equal(data.providerStatus, 400);
|
||
assert.equal(data.response, 'No endpoints found matching your data policy');
|
||
assert.ok(data.providerError.includes('No endpoints found'));
|
||
});
|
||
} finally {
|
||
global.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('POST /api/web/ai/test-profile returns 400 when no API key configured', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ openRouterApiKey: '', openRouterModel: 'openai/gpt-4o-mini' }),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/api/web/ai/test-profile',
|
||
method: 'POST',
|
||
body: 'profileId=openrouter',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
});
|
||
});
|
||
|
||
test('POST /logout returns logged-out page and clears client state', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/logout',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['cache-control'], 'no-store');
|
||
assert.ok(!res.headers['clear-site-data'], 'should not send Clear-Site-Data (client-side cleanup instead)');
|
||
const setCookie = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'].join('; ') : res.headers['set-cookie'];
|
||
assert.ok(setCookie.includes('sessionId='));
|
||
assert.ok(setCookie.includes('Max-Age=0'));
|
||
assert.ok(res.body.includes('Cleanup complete'));
|
||
assert.ok(res.body.includes('Go to login'));
|
||
});
|
||
});
|
||
|
||
// --- Heartbeat ---
|
||
|
||
test('POST /heartbeat returns 204 for valid session and does not touch session', async () => {
|
||
let touched = false;
|
||
await withServer({
|
||
sessionService: { touchSession: async () => { touched = true; } },
|
||
}, async port => {
|
||
const res = await request(port, { path: '/heartbeat', method: 'POST', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.equal(res.statusCode, 204);
|
||
assert.equal(touched, false, 'heartbeat should not touch session (liveness check only)');
|
||
});
|
||
});
|
||
|
||
test('POST /heartbeat returns 401 for missing session', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/heartbeat', method: 'POST', headers: {} });
|
||
assert.equal(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('POST /heartbeat returns 401 for invalid session', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/heartbeat', method: 'POST', headers: { Cookie: 'sessionId=bad-session' } });
|
||
assert.equal(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('authenticatedUser enforces heartbeat timeout when autoLogout enabled', async () => {
|
||
const staleLastSeen = Date.now() - (20 * 60 * 1000); // 20 min ago
|
||
let deleted = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ autoLogout: true, autoLogoutMinutes: 15, noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
},
|
||
sessionService: {
|
||
getLastSeen: async () => staleLastSeen,
|
||
deleteSession: async id => { deleted = id; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/me', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.equal(res.statusCode, 401);
|
||
assert.equal(deleted, 'test-session');
|
||
});
|
||
});
|
||
|
||
test('authenticatedUser allows request when lastSeen is within timeout', async () => {
|
||
const recentLastSeen = Date.now() - (5 * 60 * 1000); // 5 min ago
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ autoLogout: true, autoLogoutMinutes: 15, noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
},
|
||
sessionService: {
|
||
getLastSeen: async () => recentLastSeen,
|
||
deleteSession: async () => {},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/me', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.notEqual(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('authenticatedUser skips timeout check when autoLogout disabled', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ autoLogout: false, autoLogoutMinutes: 15, noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
},
|
||
sessionService: {
|
||
getLastSeen: async () => 0,
|
||
deleteSession: async () => {},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/me', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.notEqual(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('authenticatedUser allows request when lastSeen is null (no heartbeat yet)', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ autoLogout: true, autoLogoutMinutes: 15, noteFontSize: 15, codeFontSize: 12, noteMonospace: false, resumeLastNote: false, lastNoteId: '', lastNoteFolderId: '', dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm' }),
|
||
},
|
||
sessionService: {
|
||
getLastSeen: async () => null,
|
||
deleteSession: async () => {},
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/api/web/me', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.notEqual(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('GET /logout returns logged-out page and clears client state', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/logout',
|
||
method: 'GET',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['cache-control'], 'no-store');
|
||
assert.ok(!res.headers['clear-site-data'], 'should not send Clear-Site-Data (client-side cleanup instead)');
|
||
const setCookie = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'].join('; ') : res.headers['set-cookie'];
|
||
assert.ok(setCookie.includes('sessionId='));
|
||
assert.ok(setCookie.includes('Max-Age=0'));
|
||
assert.ok(res.body.includes('Cleanup complete'));
|
||
assert.ok(res.body.includes('Go to login'));
|
||
});
|
||
});
|
||
|
||
test('GET /resources/:id returns 404 for missing resource', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/resources/abcdef01234567890abcdef012345678' });
|
||
assert.equal(res.statusCode, 404);
|
||
});
|
||
});
|
||
|
||
test('GET /resources/:id returns 400 for invalid resource ID', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/resources/not-valid' });
|
||
assert.equal(res.statusCode, 400);
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/upload creates resource and returns markdown', async () => {
|
||
let createdResource = null;
|
||
let createdBuffer = null;
|
||
await withServer({
|
||
itemWriteService: {
|
||
createResource: async (_sid, resource, buffer) => {
|
||
createdResource = resource;
|
||
createdBuffer = buffer;
|
||
return { id: 'newresource01234567890abcdef01234' };
|
||
},
|
||
},
|
||
}, async port => {
|
||
const boundary = '----testboundary';
|
||
const fileContent = Buffer.from('fake image data');
|
||
const body = Buffer.concat([
|
||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="photo.png"\r\nContent-Type: image/png\r\n\r\n`),
|
||
fileContent,
|
||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||
]);
|
||
|
||
const res = await request(port, {
|
||
path: '/fragments/upload',
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: 'sessionId=test-session',
|
||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||
'Content-Length': body.length,
|
||
},
|
||
rawBody: body,
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const payload = JSON.parse(res.body);
|
||
assert.equal(payload.resourceId, 'newresource01234567890abcdef01234');
|
||
assert.ok(payload.markdown.includes('); // image markdown
|
||
assert.equal(createdResource.mime, 'image/png');
|
||
assert.equal(createdResource.filename, 'photo.png');
|
||
assert.equal(createdResource.fileExtension, 'png');
|
||
assert.ok(createdBuffer.equals(fileContent));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/upload returns 401 for unauthenticated user', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/upload',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'multipart/form-data; boundary=x' },
|
||
});
|
||
assert.equal(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/editor/:id includes folder dropdown', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'My Note', body: 'text', parentId: 'f2', updatedTime: Date.now() }),
|
||
foldersByUserId: async () => [
|
||
{ id: '__all_notes__', title: 'All Notes', parentId: '', isVirtualAllNotes: true },
|
||
{ id: 'f1', title: 'Work', parentId: '' },
|
||
{ id: 'f2', title: 'Personal', parentId: '' },
|
||
],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/editor/n1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('<select name="parentId"'));
|
||
assert.ok(!res.body.includes('<option value="__all_notes__"'));
|
||
assert.ok(res.body.includes('Work'));
|
||
assert.ok(res.body.includes('Personal'));
|
||
// f2 should be selected
|
||
assert.ok(res.body.includes('value="f2" selected'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/editor/:id allows selecting multiple upload files', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'My Note', body: 'text', parentId: 'f1', updatedTime: Date.now() }),
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Folder', parentId: '' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/editor/n1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('id="file-upload"'));
|
||
assert.ok(res.body.includes('multiple onchange="handleFilePicker(this)"'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/preview renders markdown to HTML', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/preview',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'body=**bold**+and+*italic*',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('<strong>bold</strong>'));
|
||
assert.ok(res.body.includes('<em>italic</em>'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/preview renders Joplin resource images', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/preview',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'body=!%5Bphoto%5D(%3A%2Fabcdef01234567890abcdef012345678)',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('src="/resources/abcdef01234567890abcdef012345678"'));
|
||
assert.ok(res.body.includes('class="preview-img"'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id skips nav refresh on body-only save', async () => {
|
||
const existing = { id: 'n1', title: 'Same Title', body: 'Old body', parentId: 'f1', createdTime: 1000, updatedTime: 1000 };
|
||
let callCount = 0;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => { callCount += 1; return callCount === 1 ? existing : { ...existing, body: 'New body', updatedTime: 2000 }; },
|
||
noteHeadersByUserId: async () => { throw new Error('should not fetch nav data'); },
|
||
foldersByUserId: async () => { throw new Error('should not fetch nav data'); },
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async () => ({ id: 'n1' }),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'title=Same+Title&body=New+body&parentId=f1&baseUpdatedTime=1000¤tFolderId=f1',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Saved'));
|
||
assert.ok(!res.body.includes('id="nav-panel"'), 'should NOT include nav panel OOB swap');
|
||
assert.ok(res.body.includes('id="editor-sync-state" hx-swap-oob="outerHTML"'));
|
||
assert.ok(res.body.includes('name="baseUpdatedTime" value="2000"'));
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id preserves every printable ASCII character in body', async () => {
|
||
const savedBodies = [];
|
||
const existing = { id: 'n1', title: 'T', body: '', parentId: 'f1', createdTime: 1000, updatedTime: 1000 };
|
||
let callCount = 0;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => { callCount += 1; return callCount % 2 === 1 ? existing : { ...existing, updatedTime: 2000 }; },
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [] : [{ id: 'n1', title: 'T', parentId: 'f1', updatedTime: 2000, deletedTime: 0 }],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'F', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async (_sid, _ex, updates) => { savedBodies.push(updates.body); return { id: 'n1' }; },
|
||
},
|
||
}, async port => {
|
||
for (let code = 32; code <= 126; code++) {
|
||
callCount = 0;
|
||
const ch = String.fromCharCode(code);
|
||
const bodyText = `before${ch}after`;
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `title=T&body=${encodeURIComponent(bodyText)}&baseUpdatedTime=1000¤tFolderId=f1`,
|
||
});
|
||
assert.equal(res.statusCode, 200, `status for char ${code} (${ch})`);
|
||
const saved = savedBodies[savedBodies.length - 1];
|
||
assert.equal(saved, bodyText, `body mismatch for char ${code} (${JSON.stringify(ch)}): got ${JSON.stringify(saved)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
test('PUT /fragments/editor/:id preserves space-only and space-leading body lines', async () => {
|
||
const savedBodies = [];
|
||
const existing = { id: 'n1', title: 'T', body: '', parentId: 'f1', createdTime: 1000, updatedTime: 1000 };
|
||
let callCount = 0;
|
||
await withServer({
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => { callCount += 1; return callCount % 2 === 1 ? existing : { ...existing, updatedTime: 2000 }; },
|
||
noteHeadersByUserId: async (_uid, options = {}) => options.deleted === 'only' ? [] : [{ id: 'n1', title: 'T', parentId: 'f1', updatedTime: 2000, deletedTime: 0 }],
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'F', parentId: '' }],
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async (_sid, _ex, updates) => { savedBodies.push(updates.body); return { id: 'n1' }; },
|
||
},
|
||
}, async port => {
|
||
const cases = [
|
||
{ label: 'single space', body: ' ' },
|
||
{ label: 'multiple spaces', body: ' ' },
|
||
{ label: 'space then text', body: ' hello' },
|
||
{ label: 'blank line with spaces', body: 'line one\n \nline three' },
|
||
{ label: 'trailing spaces on line', body: 'hello \nworld' },
|
||
{ label: 'leading spaces on line', body: ' hello\nworld' },
|
||
{ label: 'only spaces on multiple lines', body: ' \n \n ' },
|
||
{ label: 'tab character', body: '\there' },
|
||
{ label: 'space before newline', body: 'a \nb' },
|
||
{ label: 'spaces between blank lines', body: 'a\n \n \nb' },
|
||
];
|
||
for (const { label, body: bodyText } of cases) {
|
||
callCount = 0;
|
||
const res = await request(port, {
|
||
path: '/fragments/editor/n1',
|
||
method: 'PUT',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `title=T&body=${encodeURIComponent(bodyText)}&baseUpdatedTime=1000¤tFolderId=f1`,
|
||
});
|
||
assert.equal(res.statusCode, 200, `status for "${label}"`);
|
||
const saved = savedBodies[savedBodies.length - 1];
|
||
assert.equal(saved, bodyText, `body mismatch for "${label}": expected ${JSON.stringify(bodyText)}, got ${JSON.stringify(saved)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- Admin routes ---
|
||
|
||
const makeAdminMocks = (overrides = {}) => ({
|
||
sessionService: {
|
||
userBySessionId: async sessionId => {
|
||
if (sessionId === 'admin-session') return { id: 'admin-1', email: 'admin@example.com', sessionId, isAdmin: true };
|
||
if (sessionId === 'test-session') return { id: 'user-1', email: 'user@example.com', sessionId, isAdmin: false };
|
||
return null;
|
||
},
|
||
},
|
||
adminService: {
|
||
listUsers: async () => [{ id: 'user-1', email: 'user@example.com', full_name: '', enabled: true, created_time: 0 }],
|
||
createUser: async (email, fullName, password) => ({ id: 'new-1', email, full_name: fullName }),
|
||
resetPassword: async () => {},
|
||
setEnabled: async () => {},
|
||
deleteUser: async () => {},
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => 'some-token',
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
adminEmail: 'admin@example.com',
|
||
...overrides,
|
||
});
|
||
|
||
test('GET /settings shows admin tab for admin user', async () => {
|
||
const queries = [];
|
||
await withServer(makeAdminMocks({
|
||
database: {
|
||
query: async (sql, params = []) => {
|
||
queries.push({ sql, params });
|
||
if (sql.includes('server_version')) return { rows: [{ version: 'PostgreSQL 16.2', version_num: 160002 }] };
|
||
if (sql.includes("current_setting('default_toast_compression')")) return { rows: [{ current: 'pglz', available: ['pglz', 'lz4'] }] };
|
||
if (sql.includes('pg_column_compression(content)')) {
|
||
return {
|
||
rows: [
|
||
{ kind: 'notes', compression: 'pglz', row_count: '7', total_bytes: '4096' },
|
||
{ kind: 'attachments', compression: 'none', row_count: '2', total_bytes: '8192' },
|
||
],
|
||
};
|
||
}
|
||
return { rows: [] };
|
||
},
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('tab-admin'), 'should include admin tab panel');
|
||
assert.ok(res.body.includes('Create New User'), 'should include create user form');
|
||
assert.ok(res.body.includes('Allowed attempts per 15 minutes'));
|
||
assert.ok(res.body.includes('Database Compression'));
|
||
assert.ok(res.body.includes('Notes'));
|
||
assert.ok(res.body.includes('Attachments'));
|
||
assert.ok(res.body.includes('PostgreSQL 16.2'), 'should show pg version');
|
||
assert.ok(res.body.includes('<code>pglz</code>'));
|
||
assert.ok(res.body.includes('<code>none</code>'));
|
||
assert.ok(queries.some(q => q.sql.includes('server_version')));
|
||
assert.ok(queries.some(q => q.sql.includes('pg_column_compression(content)')));
|
||
});
|
||
});
|
||
|
||
test('POST /admin/security saves auth rate limit setting', async () => {
|
||
let saved = null;
|
||
await withServer(makeAdminMocks({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15 }),
|
||
saveSettings: async (_id, s) => s,
|
||
appSettings: async () => ({ authRateLimitAttempts: 20 }),
|
||
saveAppSettings: async s => { saved = s; return s; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/security',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'authRateLimitAttempts=35',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.deepEqual(saved, { authRateLimitAttempts: 35 });
|
||
});
|
||
});
|
||
|
||
test('POST /admin/db-compression updates default toast compression', async () => {
|
||
const queries = [];
|
||
await withServer(makeAdminMocks({
|
||
database: {
|
||
query: async (sql, params = []) => {
|
||
queries.push({ sql, params });
|
||
if (sql.includes('SELECT enumvals FROM pg_settings')) return { rows: [{ enumvals: ['pglz', 'lz4'] }] };
|
||
if (sql.includes('SELECT pg_reload_conf()')) return { rows: [{ pg_reload_conf: true }] };
|
||
return { rows: [] };
|
||
},
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/db-compression',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'defaultToastCompression=lz4',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Database%20compression%20set%20to%20lz4%20for%20new%20items'));
|
||
assert.ok(queries.some(q => q.sql.includes("ALTER SYSTEM SET default_toast_compression = 'lz4'")));
|
||
assert.ok(queries.some(q => q.sql.includes('SELECT pg_reload_conf()')));
|
||
});
|
||
});
|
||
|
||
test('POST /admin/db-compression rejects unsupported mode', async () => {
|
||
const queries = [];
|
||
await withServer(makeAdminMocks({
|
||
database: {
|
||
query: async (sql, params = []) => {
|
||
queries.push({ sql, params });
|
||
if (sql.includes('SELECT enumvals FROM pg_settings')) return { rows: [{ enumvals: ['pglz', 'lz4'] }] };
|
||
return { rows: [] };
|
||
},
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/db-compression',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'defaultToastCompression=snappy',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Unsupported%20compression%20mode'));
|
||
assert.ok(!queries.some(q => q.sql.includes('ALTER SYSTEM SET default_toast_compression')));
|
||
});
|
||
});
|
||
|
||
test('GET /settings does not show admin tab for non-admin user', async () => {
|
||
await withServer(makeAdminMocks(), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings',
|
||
headers: { Cookie: 'sessionId=test-session' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(!res.body.includes('tab-admin'), 'should not include admin tab panel');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users creates user and redirects', async () => {
|
||
let created = null;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async (email, fullName, password) => { created = { email, fullName, password }; return { id: 'new-1' }; },
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'email=new%40example.com&fullName=New+User&password=secret123',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.equal(created && created.email, 'new@example.com');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users redirects non-admin to /', async () => {
|
||
await withServer(makeAdminMocks(), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'email=bad%40example.com&password=secret123',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(res.headers.location, '/');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users/:id/password resets password', async () => {
|
||
let resetId = null;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async () => {},
|
||
resetPassword: async (userId) => { resetId = userId; },
|
||
setEnabled: async () => {},
|
||
deleteUser: async () => {},
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/password',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'password=newpass123',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(resetId, 'user-1');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users/:id/disable disables user', async () => {
|
||
let disabledId = null, disabledVal = null;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async () => {},
|
||
resetPassword: async () => {},
|
||
setEnabled: async (id, val) => { disabledId = id; disabledVal = val; },
|
||
deleteUser: async () => {},
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/disable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(disabledId, 'user-1');
|
||
assert.equal(disabledVal, false);
|
||
});
|
||
});
|
||
|
||
test('POST /settings/profile updates profile', async () => {
|
||
let dbUpdated = false;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async () => {},
|
||
resetPassword: async () => {},
|
||
setEnabled: async () => {},
|
||
deleteUser: async () => {},
|
||
updateProfile: async () => {},
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
database: {
|
||
query: async (sql, params) => {
|
||
if (sql.includes('UPDATE users SET full_name')) {
|
||
dbUpdated = true;
|
||
assert.equal(params[0], 'Test User');
|
||
return { rows: [] };
|
||
}
|
||
return { rows: [{ session_id: 'test-session', id: 'user1', email: 'user@example.com', full_name: '', is_admin: 1, can_upload: 1, email_confirmed: 1, account_type: 0, created_time: Date.now(), updated_time: Date.now(), enabled: 1, session_created_time: Date.now() }] };
|
||
},
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/profile',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'fullName=Test+User&email=user%40example.com',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.ok(dbUpdated);
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/folder-notes with __all_notes__ returns all notes', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByFolder: async (_uid, folderId) => folderId === '__all__' ? [
|
||
{ id: 'n1', title: 'Note 1', parentId: 'f1', updatedTime: 0 },
|
||
{ id: 'n2', title: 'Note 2', parentId: 'f2', updatedTime: 0 },
|
||
] : [],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/folder-notes?folderId=__all_notes__' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Note 1'));
|
||
assert.ok(res.body.includes('Note 2'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/folder-notes shows lock icon for notes inside vault notebooks', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByFolder: async () => [{ id: 'n1', title: 'Vault Note', parentId: 'vault-1', updatedTime: 0, isEncrypted: false }],
|
||
folderNoteCountsByUserId: async () => new Map([['vault-1', 1], ['__all__', 1], ['__trash__', 0]]),
|
||
},
|
||
vaultService: {
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
getVaultByFolderId: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/folder-notes?folderId=vault-1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('note-lock-icon'));
|
||
assert.ok(res.body.includes('data-vault-id="vault-1"'));
|
||
});
|
||
});
|
||
|
||
// --- Health check ---
|
||
|
||
test('GET /health returns ok', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/health', headers: {} });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.body, 'ok');
|
||
});
|
||
});
|
||
|
||
// --- Settings security ---
|
||
|
||
test('POST /settings/security saves auto-logout settings', async () => {
|
||
let saved = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({ noteFontSize: 15 }),
|
||
saveSettings: async (_id, s) => { saved = s; return s; },
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/security',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'autoLogout=true&autoLogoutMinutes=30',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('tab=security'));
|
||
assert.equal(saved.autoLogout, 'true');
|
||
});
|
||
});
|
||
|
||
// --- Settings password ---
|
||
|
||
test('POST /settings/password blocks docker admin', async () => {
|
||
await withServer(makeAdminMocks(), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/password',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'currentPassword=old&newPassword=new&confirmPassword=new',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('managed+via+deployment'));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/password rejects missing current password', async () => {
|
||
await withServer(makeAdminMocks({
|
||
sessionService: {
|
||
userBySessionId: async sessionId => sessionId === 'test-session' ? { id: 'user-1', email: 'user@example.com', sessionId, isAdmin: false } : null,
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/password',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'newPassword=abc&confirmPassword=abc',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Current+password+required'));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/password rejects mismatched passwords', async () => {
|
||
await withServer(makeAdminMocks({
|
||
sessionService: {
|
||
userBySessionId: async sessionId => sessionId === 'test-session' ? { id: 'user-1', email: 'user@example.com', sessionId, isAdmin: false } : null,
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/password',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'currentPassword=old&newPassword=abc&confirmPassword=xyz',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Passwords+do+not+match'));
|
||
});
|
||
});
|
||
|
||
// --- MFA settings routes ---
|
||
|
||
test('POST /settings/mfa/setup generates seed and redirects', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/setup',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: '',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('mfaSetup='));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/setup rejects when already enabled', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => 'EXISTINGSEED',
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/setup',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: '',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('MFA+already+enabled'));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/verify saves seed on valid code', async () => {
|
||
const { hotp, base32Decode } = require('../app/auth/mfaService');
|
||
const seed = 'JBSWY3DPEHPK3PXP';
|
||
const now = Date.now();
|
||
const code = hotp(base32Decode(seed), Math.floor(now / 30000));
|
||
let savedSeed = null;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async (_id, s) => { savedSeed = s; return true; },
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/verify',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `seed=${seed}&totp=${code}`,
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('mfaEnabled=1'));
|
||
assert.equal(savedSeed, seed);
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/verify rejects invalid code', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/verify',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'seed=JBSWY3DPEHPK3PXP&totp=000000',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Invalid+code'));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/cancel redirects to settings', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/cancel',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: '',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('tab=security'));
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/disable clears seed on valid code', async () => {
|
||
const { hotp, base32Decode } = require('../app/auth/mfaService');
|
||
const seed = 'JBSWY3DPEHPK3PXP';
|
||
const now = Date.now();
|
||
const code = hotp(base32Decode(seed), Math.floor(now / 30000));
|
||
let cleared = false;
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => seed,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => { cleared = true; return true; },
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/disable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `totp=${code}`,
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.ok(cleared);
|
||
});
|
||
});
|
||
|
||
test('POST /settings/mfa/disable rejects invalid code', async () => {
|
||
await withServer({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => 'JBSWY3DPEHPK3PXP',
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/settings/mfa/disable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'totp=000000',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Invalid+code'));
|
||
});
|
||
});
|
||
|
||
// --- Admin enable / delete / MFA ---
|
||
|
||
test('POST /admin/users/:id/enable enables user', async () => {
|
||
let enabledId = null, enabledVal = null;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async () => {},
|
||
resetPassword: async () => {},
|
||
setEnabled: async (id, val) => { enabledId = id; enabledVal = val; },
|
||
deleteUser: async () => {},
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/enable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(enabledId, 'user-1');
|
||
assert.equal(enabledVal, true);
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users/:id/delete deletes user', async () => {
|
||
let deletedId = null;
|
||
await withServer(makeAdminMocks({
|
||
adminService: {
|
||
listUsers: async () => [],
|
||
createUser: async () => {},
|
||
resetPassword: async () => {},
|
||
setEnabled: async () => {},
|
||
deleteUser: async (id) => { deletedId = id; },
|
||
updateProfile: async () => ({}),
|
||
verifyPassword: async () => null,
|
||
changePassword: async () => {},
|
||
adminEmail: 'admin@example.com',
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/delete',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.equal(deletedId, 'user-1');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users/:id/mfa/enable sets TOTP seed', async () => {
|
||
let seedUserId = null;
|
||
await withServer(makeAdminMocks({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async (id) => { seedUserId = id; return true; },
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/mfa/enable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.equal(seedUserId, 'user-1');
|
||
});
|
||
});
|
||
|
||
test('POST /admin/users/:id/mfa/disable clears TOTP seed', async () => {
|
||
let clearedId = null;
|
||
await withServer(makeAdminMocks({
|
||
settingsService: {
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_id, s) => s,
|
||
getTotpSeed: async () => 'SEED',
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async (id) => { clearedId = id; return true; },
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/users/user-1/mfa/disable',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('saved=1'));
|
||
assert.equal(clearedId, 'user-1');
|
||
});
|
||
});
|
||
|
||
test('GET /settings shows backup section for admin when configured', async () => {
|
||
await withServer(makeAdminMocks({
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [{ name: 'joplock-backup-2026.dump', createdTime: 0, size: 12 }],
|
||
startBackupJob: async () => ({}),
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'joplock-backup-2026.dump', createdTime: 0 }),
|
||
deleteBackup: async () => ({}),
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, { path: '/settings', headers: { Cookie: 'sessionId=admin-session' } });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Backup & Restore'));
|
||
assert.ok(res.body.includes('joplock-backup-2026.dump'));
|
||
});
|
||
});
|
||
|
||
test('POST /admin/backups creates backup and redirects', async () => {
|
||
let created = null;
|
||
await withServer(makeAdminMocks({
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [],
|
||
startBackupJob: async options => { created = options; return {}; },
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'x.dump', createdTime: 0 }),
|
||
deleteBackup: async () => ({}),
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, { path: '/admin/backups', method: 'POST', headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'compressionMode=zstd' });
|
||
assert.equal(res.statusCode, 302);
|
||
assert.deepEqual(created, { mode: 'zstd' });
|
||
assert.ok(res.headers.location.includes('Backup+started'));
|
||
});
|
||
});
|
||
|
||
test('POST /admin/backups/:name/delete deletes backup and redirects', async () => {
|
||
let deleted = '';
|
||
await withServer(makeAdminMocks({
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [],
|
||
startBackupJob: async () => ({}),
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'x.dump', createdTime: 0 }),
|
||
deleteBackup: async fileName => { deleted = fileName; return {}; },
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/backups/joplock-backup-2026.dump/delete',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session' },
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.equal(deleted, 'joplock-backup-2026.dump');
|
||
assert.ok(res.headers.location.includes('Backup+deleted'));
|
||
});
|
||
});
|
||
|
||
test('POST /admin/restore requires typed confirmation', async () => {
|
||
await withServer(makeAdminMocks(), async port => {
|
||
const res = await request(port, {
|
||
path: '/admin/restore',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=admin-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'backupName=joplock-backup.dump&confirm=nope',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Type+RESTORE+to+confirm'));
|
||
});
|
||
});
|
||
|
||
test('recovery login and backup flow works without normal Joplin auth', async () => {
|
||
let issuedToken = '';
|
||
let validatedToken = '';
|
||
let created = null;
|
||
await withServer({
|
||
sessionService: {
|
||
userBySessionId: async () => null,
|
||
touchSession: async () => {},
|
||
getLastSeen: async () => null,
|
||
deleteSession: async () => {},
|
||
},
|
||
recoveryService: {
|
||
isEnabled: () => true,
|
||
createSession: password => {
|
||
if (password !== 'secret') return '';
|
||
issuedToken = 'recovery-token';
|
||
return issuedToken;
|
||
},
|
||
validateSession: token => {
|
||
validatedToken = token;
|
||
return token === 'recovery-token';
|
||
},
|
||
endSession: () => {},
|
||
},
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [{ name: 'joplock-backup.dump', createdTime: 0, size: 4 }],
|
||
startBackupJob: async options => { created = options; return {}; },
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'joplock-backup.dump', createdTime: 0 }),
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}, async port => {
|
||
const login = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'password=secret',
|
||
});
|
||
assert.equal(login.statusCode, 302);
|
||
assert.ok(String(login.headers['set-cookie']).includes('joplockRecoverySession=recovery-token'));
|
||
const res = await request(port, {
|
||
path: '/recovery/backups',
|
||
method: 'POST',
|
||
headers: { Cookie: 'joplockRecoverySession=recovery-token', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'compressionMode=fast',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.deepEqual(created, { mode: 'fast' });
|
||
assert.equal(validatedToken, 'recovery-token');
|
||
assert.ok(res.headers.location.includes('Backup+started'));
|
||
});
|
||
});
|
||
|
||
test('recovery login returns 429 after repeated invalid passwords', async () => {
|
||
await withServer({
|
||
rateLimitService: createRateLimitService(),
|
||
settingsService: {
|
||
appSettings: async () => ({ authRateLimitAttempts: 2 }),
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
saveAppSettings: async settings => settings,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
recoveryService: {
|
||
isEnabled: () => true,
|
||
createSession: password => password === 'secret' ? 'recovery-token' : '',
|
||
validateSession: token => token === 'recovery-token',
|
||
endSession: () => {},
|
||
},
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [],
|
||
startBackupJob: async () => ({}),
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'joplock-backup.dump', createdTime: 0 }),
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}, async port => {
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
const res = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.40' },
|
||
body: 'password=wrong',
|
||
});
|
||
assert.equal(res.statusCode, 302);
|
||
assert.ok(res.headers.location.includes('Invalid+recovery+password'));
|
||
}
|
||
|
||
const blocked = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.40' },
|
||
body: 'password=wrong',
|
||
});
|
||
assert.equal(blocked.statusCode, 429);
|
||
assert.equal(blocked.headers['retry-after'], '900');
|
||
assert.ok(blocked.body.includes('Too many recovery login attempts. Try again later.'));
|
||
});
|
||
});
|
||
|
||
test('recovery login clears failure streak after successful password', async () => {
|
||
await withServer({
|
||
rateLimitService: createRateLimitService(),
|
||
settingsService: {
|
||
appSettings: async () => ({ authRateLimitAttempts: 2 }),
|
||
settingsByUserId: async () => ({}),
|
||
saveSettings: async (_userId, settings) => settings,
|
||
saveAppSettings: async settings => settings,
|
||
getTotpSeed: async () => null,
|
||
setTotpSeed: async () => true,
|
||
clearTotpSeed: async () => true,
|
||
},
|
||
recoveryService: {
|
||
isEnabled: () => true,
|
||
createSession: password => password === 'secret' ? 'recovery-token' : '',
|
||
validateSession: token => token === 'recovery-token',
|
||
endSession: () => {},
|
||
},
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [],
|
||
startBackupJob: async () => ({}),
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'joplock-backup.dump', createdTime: 0 }),
|
||
startRestoreJob: async () => ({}),
|
||
currentStatus: () => ({ state: 'idle', type: '', message: '', error: '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: 'idle' }),
|
||
},
|
||
}, async port => {
|
||
const firstFailure = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.41' },
|
||
body: 'password=wrong',
|
||
});
|
||
assert.equal(firstFailure.statusCode, 302);
|
||
|
||
const success = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.41' },
|
||
body: 'password=secret',
|
||
});
|
||
assert.equal(success.statusCode, 302);
|
||
assert.ok(String(success.headers['set-cookie']).includes('joplockRecoverySession=recovery-token'));
|
||
|
||
const secondFailure = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.41' },
|
||
body: 'password=wrong-again',
|
||
});
|
||
assert.equal(secondFailure.statusCode, 302);
|
||
|
||
const thirdFailure = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.41' },
|
||
body: 'password=wrong-final',
|
||
});
|
||
assert.equal(thirdFailure.statusCode, 302);
|
||
|
||
const blocked = await request(port, {
|
||
path: '/recovery/login',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '198.51.100.41' },
|
||
body: 'password=wrong-blocked',
|
||
});
|
||
assert.equal(blocked.statusCode, 429);
|
||
});
|
||
});
|
||
|
||
test('failed restore keeps maintenance mode active and blocks normal routes', async () => {
|
||
let restoreCalled = false;
|
||
let jobState = 'idle';
|
||
await withServer({
|
||
recoveryService: {
|
||
isEnabled: () => true,
|
||
createSession: () => '',
|
||
validateSession: token => token === 'recovery-token',
|
||
endSession: () => {},
|
||
},
|
||
backupService: {
|
||
isConfigured: () => true,
|
||
isBusy: () => false,
|
||
activeOperation: () => '',
|
||
listBackups: async () => [{ name: 'joplock-backup.dump', createdTime: 0, size: 4 }],
|
||
startBackupJob: async () => ({}),
|
||
backupPath: async () => ({ path: __filename, size: 1, name: 'joplock-backup.dump', createdTime: 0 }),
|
||
startRestoreJob: async () => { restoreCalled = true; jobState = 'failed'; return {}; },
|
||
currentStatus: () => ({ state: jobState, type: 'restore', message: jobState === 'failed' ? 'Restore failed' : 'Restore running', error: jobState === 'failed' ? 'restore failed' : '', stderrTail: '' }),
|
||
waitForIdle: async () => ({ state: jobState }),
|
||
},
|
||
}, async port => {
|
||
const restore = await request(port, {
|
||
path: '/recovery/restore',
|
||
method: 'POST',
|
||
headers: { Cookie: 'joplockRecoverySession=recovery-token', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'backupName=joplock-backup.dump&confirm=RESTORE',
|
||
});
|
||
assert.equal(restore.statusCode, 302);
|
||
assert.equal(restoreCalled, true);
|
||
assert.ok(restore.headers.location.includes('Restore+started'));
|
||
const blocked = await request(port, { path: '/', headers: { Cookie: 'sessionId=test-session' } });
|
||
assert.equal(blocked.statusCode, 503);
|
||
assert.ok(blocked.body.includes('maintenance mode'));
|
||
const recovery = await request(port, { path: '/recovery', headers: { Cookie: 'joplockRecoverySession=recovery-token' } });
|
||
assert.equal(recovery.statusCode, 200);
|
||
});
|
||
});
|
||
|
||
// --- History routes ---
|
||
|
||
test('GET /fragments/history/:noteId returns history modal', async () => {
|
||
await withServer({
|
||
historyService: {
|
||
saveSnapshot: async () => {},
|
||
listSnapshots: async (noteId) => [{ id: 's1', noteId, savedTime: Date.now(), title: 'Test' }],
|
||
getSnapshot: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/history/n1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('data-snapshot-id="s1"'));
|
||
assert.ok(res.body.includes('history-list'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/history-snapshot/:id returns snapshot preview', async () => {
|
||
await withServer({
|
||
historyService: {
|
||
saveSnapshot: async () => {},
|
||
listSnapshots: async () => [],
|
||
getSnapshot: async (id) => id === 's1' ? { id: 's1', body: 'Snapshot body' } : null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/history-snapshot/s1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Snapshot body'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/history-snapshot/:id returns 404 for missing snapshot', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/fragments/history-snapshot/missing' });
|
||
assert.equal(res.statusCode, 404);
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/history/:noteId/restore/:snapshotId rejects plaintext restore inside vault', async () => {
|
||
let updated = false;
|
||
await withServer({
|
||
historyService: {
|
||
saveSnapshot: async () => {},
|
||
listSnapshots: async () => [],
|
||
getSnapshot: async id => id === 's1' ? { id: 's1', noteId: 'n1', title: 'Secret', body: 'plain snapshot body' } : null,
|
||
},
|
||
itemService: {
|
||
noteByUserIdAndJopId: async () => ({ id: 'n1', title: 'Secret', body: 'cipher', parentId: 'vault-1', createdTime: 1000, updatedTime: 1000 }),
|
||
},
|
||
itemWriteService: {
|
||
updateNote: async () => { updated = true; return { id: 'n1' }; },
|
||
},
|
||
vaultService: {
|
||
getVaultByFolderId: async (_uid, folderId) => folderId === 'vault-1' ? { folderId: 'vault-1' } : null,
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/history/n1/restore/s1',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'currentFolderId=vault-1',
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.equal(updated, false);
|
||
assert.ok(res.body.includes('Vault notes must be saved encrypted'));
|
||
});
|
||
});
|
||
|
||
// --- Folder delete ---
|
||
|
||
test('DELETE /fragments/folders/:id deletes folder and returns nav', async () => {
|
||
const moved = [];
|
||
let createdFolder = null;
|
||
let deletedId = null;
|
||
await withServer({
|
||
itemService: {
|
||
folderByUserIdAndJopId: async (_uid, id) => id === 'f1' ? { id: 'f1', title: 'Work', parentId: '' } : null,
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Work', parentId: '' }],
|
||
notesByUserId: async (_uid, options = {}) => options.folderId === 'f1' ? [
|
||
{ id: 'n1', title: 'Note 1', body: 'A', parentId: 'f1', createdTime: 1 },
|
||
] : [],
|
||
},
|
||
itemWriteService: {
|
||
createFolder: async (_sid, folder) => { createdFolder = folder; return { id: 'general-id' }; },
|
||
updateNote: async (_sid, existing, updates) => { moved.push({ id: existing.id, parentId: updates.parentId }); return { id: existing.id }; },
|
||
deleteFolder: async (_sess, id) => { deletedId = id; },
|
||
updateFolder: async () => ({}),
|
||
createNote: async () => ({}),
|
||
deleteNote: async () => {},
|
||
trashNote: async () => {},
|
||
restoreNote: async () => {},
|
||
createResource: async () => ({}),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/folders/f1',
|
||
method: 'DELETE',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.deepEqual(createdFolder, { title: 'General', parentId: '' });
|
||
assert.deepEqual(moved, [{ id: 'n1', parentId: 'general-id' }]);
|
||
assert.equal(deletedId, 'f1');
|
||
});
|
||
});
|
||
|
||
// --- Mobile routes ---
|
||
|
||
test('GET /fragments/mobile/folders returns mobile folder list', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'Work' }],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 3], ['f1', 2], ['__trash__', 0]]),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/folders' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('All Notes'));
|
||
assert.ok(res.body.includes('Work'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/folders shows vault lock button for vault notebooks', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'vault-1', title: 'Vault 1' }],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 3], ['vault-1', 2], ['__trash__', 0]]),
|
||
},
|
||
vaultService: {
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/folders' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('mobile-vault-folder-lock'));
|
||
assert.ok(res.body.includes('data-folder-id="vault-1"'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/notes returns mobile note list', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByFolder: async () => [{ id: 'n1', title: 'Note 1', parentId: 'f1' }],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 1], ['f1', 1], ['__trash__', 0]]),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/notes?folderId=f1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Note 1'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/notes shows lock icon for notes inside vault notebooks', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
noteHeadersByFolder: async () => [{ id: 'n1', title: 'Vault Note', parentId: 'vault-1', isEncrypted: false }],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 1], ['vault-1', 1], ['__trash__', 0]]),
|
||
},
|
||
vaultService: {
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
getVaultByFolderId: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/notes?folderId=vault-1' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('note-lock-icon'));
|
||
assert.ok(res.body.includes('data-vault-id="vault-1"'));
|
||
});
|
||
});
|
||
|
||
test('POST /fragments/mobile/notes/new creates note and returns X-Mobile-Note-Id', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
foldersByUserId: async () => [{ id: 'f1', title: 'General' }],
|
||
noteHeadersByFolder: async () => [{ id: 'note-created', title: 'Untitled note', parentId: 'f1' }],
|
||
folderNoteCountsByUserId: async () => new Map([['__all__', 1], ['f1', 1], ['__trash__', 0]]),
|
||
},
|
||
itemWriteService: {
|
||
createNote: async () => ({ id: 'note-created' }),
|
||
createFolder: async () => ({}),
|
||
deleteFolder: async () => {},
|
||
updateFolder: async () => ({}),
|
||
deleteNote: async () => {},
|
||
trashNote: async () => {},
|
||
restoreNote: async () => {},
|
||
updateNote: async () => ({}),
|
||
createResource: async () => ({}),
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, {
|
||
path: '/fragments/mobile/notes/new',
|
||
method: 'POST',
|
||
headers: { Cookie: 'sessionId=test-session', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'folderId=__all__',
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.headers['x-mobile-note-id'], 'note-created');
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/search returns search results', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async () => [{ id: 'n1', title: 'Found', parentId: 'f1' }],
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/search?q=test' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('Found'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/search shows lock icon for notes inside vault notebooks', async () => {
|
||
await withServer({
|
||
itemService: {
|
||
searchNotes: async () => [{ id: 'n1', title: 'Vault Found', parentId: 'vault-1', isEncrypted: false }],
|
||
},
|
||
vaultService: {
|
||
getVaultFolderIdSet: async () => new Set(['vault-1']),
|
||
getVaultByFolderId: async () => null,
|
||
},
|
||
}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/search?q=vault' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('note-lock-icon'));
|
||
assert.ok(res.body.includes('data-vault-id="vault-1"'));
|
||
});
|
||
});
|
||
|
||
test('GET /fragments/mobile/search returns empty for no query', async () => {
|
||
await withServer({}, async port => {
|
||
const res = await request(port, { path: '/fragments/mobile/search?q=' });
|
||
assert.equal(res.statusCode, 200);
|
||
assert.ok(res.body.includes('No results'));
|
||
});
|
||
});
|