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, 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('')); 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')); assert.ok(!res.body.includes('Hello')); }); }); 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('
')); assert.ok(res.body.includes('hx-put="/fragments/editor/n1"')); assert.ok(!res.body.includes('
\n\t\t\t\t
Select a note
')); }); }); 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('
Select a note
')); 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, '/'); assert.equal(folderCreates, 1); assert.equal(noteCreates, 1); }); } 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('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
')); }); }); 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('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('![photo.png](:/')); // 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('