import { mkdtemp } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, test } from 'vitest'; import type { FastifyInstance } from 'fastify'; import type { GatewayConfig } from '../src/config.js'; import { buildServer } from '../src/http/server.js'; import type { Note, Notebook, NotebookGrant } from '../src/domain/types.js'; import { FakeAdapter } from './fake-adapter.js'; const ROOT = '11111111111111111111111111111111'; const CHILD = '22222222222222222222222222222222'; const PRIVATE = '33333333333333333333333333333333'; const NOTE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const PRIVATE_NOTE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; const notebooks: Notebook[] = [ { id: ROOT, parent_id: '', title: 'Root' }, { id: CHILD, parent_id: ROOT, title: 'Allowed child' }, { id: PRIVATE, parent_id: '', title: 'Private' }, ]; const note = (noteId: string, parentId: string, title: string): Note => ({ id: noteId, parent_id: parentId, title, body: `${title} body`, created_time: 1, updated_time: 2, deleted_time: 0, is_todo: 0, is_conflict: 0, }); describe('gateway HTTP API', () => { let app: FastifyInstance | undefined; afterEach(async () => { if (app) await app.close(); app = undefined; }); test('authenticates a client and filters inaccessible notes and search results', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.notes = [note(NOTE, CHILD, 'visible'), note(PRIVATE_NOTE, PRIVATE, 'secret')]; app = await buildServer(await config(), fake); const token = await issueToken(app); const list = await app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } }); expect(list.statusCode).toBe(200); expect(list.json().items.map((item: Note) => item.id)).toEqual([NOTE]); const hidden = await app.inject({ method: 'GET', url: `/api/v1/notes/${PRIVATE_NOTE}`, headers: { authorization: `Bearer ${token}` } }); const absent = await app.inject({ method: 'GET', url: '/api/v1/notes/does-not-exist', headers: { authorization: `Bearer ${token}` } }); expect(hidden.statusCode).toBe(404); expect(hidden.json().error.code).toBe('NOTE_NOT_FOUND'); expect(absent.json().error.code).toBe('NOTE_NOT_FOUND'); const search = await app.inject({ method: 'GET', url: '/api/v1/search?query=body', headers: { authorization: `Bearer ${token}` } }); expect(search.json().items.map((item: Note) => item.id)).toEqual([NOTE]); }); test('returns only path ancestors when access starts at a nested notebook', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); app = await buildServer(await config([{ notebook_id: CHILD, access: 'read' }]), fake); const token = await issueToken(app); const response = await app.inject({ method: 'GET', url: '/api/v1/notebooks', headers: { authorization: `Bearer ${token}` } }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual([ expect.objectContaining({ id: ROOT, access: 'path_only', children: [expect.objectContaining({ id: CHILD, access: 'read' })] }), ]); expect(JSON.stringify(response.json())).not.toContain('Private'); }); test('does not access local data when pre-operation sync fails', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.failSyncCalls.add(1); app = await buildServer(await config(), fake); const token = await issueToken(app); const response = await app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } }); expect(response.statusCode).toBe(503); expect(response.json().error.code).toBe('SYNC_UNAVAILABLE'); expect(fake.dataCalls).toBe(0); }); test('reports degraded local success after a post-mutation sync failure', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.failSyncCalls.add(2); app = await buildServer(await config(), fake); const token = await issueToken(app); const response = await app.inject({ method: 'POST', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` }, payload: { parent_id: CHILD, title: 'new', body: 'local body' }, }); expect(response.statusCode).toBe(202); expect(response.json()).toEqual(expect.objectContaining({ title: 'new', sync: expect.objectContaining({ status: 'pending', local_change_applied: true }) })); expect(fake.notes.some(item => item.title === 'new')).toBe(true); }); test('persists and reports the automatic write grant for a created top-level notebook', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); app = await buildServer(await config([]), fake); const token = await issueToken(app); const created = await app.inject({ method: 'POST', url: '/api/v1/notebooks', headers: { authorization: `Bearer ${token}` }, payload: { title: 'Client root' }, }); expect(created.statusCode).toBe(201); const createdId = created.json().id as string; const me = await app.inject({ method: 'GET', url: '/api/v1/me', headers: { authorization: `Bearer ${token}` } }); expect(me.statusCode).toBe(200); expect(me.json().permissions.notebooks).toContainEqual({ notebook_id: createdId, access: 'write' }); }); test('allows reading trash under a deleted original notebook but rejects restoration', async () => { const fake = new FakeAdapter(); const deletedFolder = { id: CHILD, parent_id: ROOT, title: 'Deleted', deleted_time: 10 }; fake.notebooks = [notebooks[0]!, deletedFolder]; fake.notes = [{ ...note(NOTE, CHILD, 'trashed'), deleted_time: 20 }]; app = await buildServer(await config(), fake); const token = await issueToken(app); const read = await app.inject({ method: 'GET', url: `/api/v1/trash/notes/${NOTE}`, headers: { authorization: `Bearer ${token}` } }); expect(read.statusCode).toBe(200); const restore = await app.inject({ method: 'POST', url: `/api/v1/trash/notes/${NOTE}/restore`, headers: { authorization: `Bearer ${token}` } }); expect(restore.statusCode).toBe(409); expect(restore.json().error.code).toBe('RESTORE_DESTINATION_MISSING'); }); test('serializes concurrent requests through one profile owner', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.notes = [note(NOTE, CHILD, 'visible')]; fake.delayMs = 5; app = await buildServer(await config(), fake); const token = await issueToken(app); await Promise.all([ app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } }), app.inject({ method: 'GET', url: `/api/v1/notes/${NOTE}`, headers: { authorization: `Bearer ${token}` } }), ]); expect(fake.maxConcurrentCalls).toBe(1); }); test('exposes the same permission-filtered data through GraphQL', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.notes = [note(NOTE, CHILD, 'visible'), note(PRIVATE_NOTE, PRIVATE, 'secret')]; app = await buildServer(await config(), fake); const token = await issueToken(app); const response = await app.inject({ method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` }, payload: { query: '{ notes { items { id title } page_info { page has_more } } notebooks { id } }' }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ data: { notes: { items: [{ id: NOTE, title: 'visible' }], page_info: { page: 1, has_more: false } }, notebooks: expect.any(Array), } }); expect(fake.syncCalls).toBe(1); }); test('performs one post-sync for a GraphQL mutation document and exposes degraded status', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); fake.failSyncCalls.add(2); app = await buildServer(await config(), fake); const token = await issueToken(app); const response = await app.inject({ method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` }, payload: { query: 'mutation { create_note(input: { parent_id: "22222222222222222222222222222222", title: "new", body: "body" }) { id sync { status local_change_applied operation_id } } }', }, }); expect(response.statusCode).toBe(200); expect(response.json().data.create_note.sync).toEqual(expect.objectContaining({ status: 'pending', local_change_applied: true })); expect(response.json().data.create_note.sync.operation_id).toEqual(expect.any(String)); expect(fake.syncCalls).toBe(2); }); test('returns stable GraphQL error extensions', async () => { const fake = new FakeAdapter(); fake.notebooks = structuredClone(notebooks); app = await buildServer(await config(), fake); const token = await issueToken(app); const response = await app.inject({ method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` }, payload: { query: '{ notes(page: 0) { items { id } } }' }, }); expect(response.statusCode).toBe(200); expect(response.json().errors[0].extensions).toEqual(expect.objectContaining({ code: 'PAGINATION_INVALID', request_id: expect.any(String), })); }); }); async function config(grants: NotebookGrant[] = [{ notebook_id: ROOT, access: 'write' }]): Promise { const directory = await mkdtemp(join(tmpdir(), 'jcg-test-')); return { server: { host: '127.0.0.1', port: 8080, trust_proxy: false }, auth: { issuer: 'test', audience: 'test-api', token_ttl_seconds: 3600 }, joplin: { executable: 'joplin', profile_dir: '/test/profile', api_host: '127.0.0.1', api_port: 41184, api_token: 'test', command_timeout_ms: 1000, server_start_timeout_ms: 1000, periodic_sync_seconds: 86400, }, state_file: join(directory, 'state.json'), clients: [{ client_id: 'client-a', enabled: true, client_secret: 'secret-a', permissions: { full_access: false, create_notebooks: true, create_tags: true, notebooks: grants }, }], }; } async function issueToken(server: FastifyInstance): Promise { const response = await server.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: 'grant_type=client_credentials&client_id=client-a&client_secret=secret-a', }); expect(response.statusCode).toBe(200); return response.json().access_token as string; }