Initial implementation
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { JoplinAdapter, NoteQuery } from '../src/adapter/joplin-adapter.js';
|
||||
import type { ChangePage, Note, Notebook, Pagination, RevisionRecord, Tag } from '../src/domain/types.js';
|
||||
|
||||
const id = () => randomBytes(16).toString('hex');
|
||||
|
||||
export class FakeAdapter implements JoplinAdapter {
|
||||
public notebooks: Notebook[] = [];
|
||||
public notes: Note[] = [];
|
||||
public tags: Tag[] = [];
|
||||
public revisions: RevisionRecord[] = [];
|
||||
public syncCalls = 0;
|
||||
public dataCalls = 0;
|
||||
public failSyncCalls = new Set<number>();
|
||||
public delayMs = 0;
|
||||
public concurrentCalls = 0;
|
||||
public maxConcurrentCalls = 0;
|
||||
|
||||
public async prepare(): Promise<void> {}
|
||||
public async close(): Promise<void> {}
|
||||
|
||||
public async sync(): Promise<void> {
|
||||
await this.track(async () => {
|
||||
this.syncCalls += 1;
|
||||
if (this.failSyncCalls.has(this.syncCalls)) throw new Error('sync offline');
|
||||
});
|
||||
}
|
||||
|
||||
public async listNotebooks(): Promise<Notebook[]> {
|
||||
return this.data(() => structuredClone(this.notebooks));
|
||||
}
|
||||
|
||||
public async getNotebook(notebookId: string): Promise<Notebook | null> {
|
||||
return this.data(() => structuredClone(this.notebooks.find(item => item.id === notebookId) ?? null));
|
||||
}
|
||||
|
||||
public async createNotebook(input: { title: string; parent_id?: string }): Promise<Notebook> {
|
||||
return this.data(() => {
|
||||
const notebook: Notebook = { id: id(), parent_id: input.parent_id ?? '', title: input.title, created_time: Date.now(), updated_time: Date.now() };
|
||||
this.notebooks.push(notebook);
|
||||
return structuredClone(notebook);
|
||||
});
|
||||
}
|
||||
|
||||
public async listNotes(query: NoteQuery): Promise<Note[]> {
|
||||
return this.data(() => structuredClone(this.notes.filter(note =>
|
||||
(query.parent_id === undefined || note.parent_id === query.parent_id) && (query.include_deleted || !note.deleted_time))));
|
||||
}
|
||||
|
||||
public async getNote(noteId: string, includeDeleted = false): Promise<Note | null> {
|
||||
return this.data(() => {
|
||||
const note = this.notes.find(item => item.id === noteId && (includeDeleted || !item.deleted_time));
|
||||
return structuredClone(note ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
public async createNote(input: { parent_id: string; title: string; body: string }): Promise<Note> {
|
||||
return this.data(() => {
|
||||
const now = Date.now();
|
||||
const note: Note = { id: id(), ...input, created_time: now, updated_time: now, deleted_time: 0, is_todo: 0, is_conflict: 0 };
|
||||
this.notes.push(note);
|
||||
return structuredClone(note);
|
||||
});
|
||||
}
|
||||
|
||||
public async updateNote(noteId: string, input: Partial<Pick<Note, 'parent_id' | 'title' | 'body' | 'deleted_time'>>): Promise<Note> {
|
||||
return this.data(() => {
|
||||
const note = this.notes.find(item => item.id === noteId);
|
||||
if (!note) throw new Error('not found');
|
||||
Object.assign(note, input, { updated_time: Date.now() });
|
||||
return structuredClone(note);
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteNote(noteId: string): Promise<void> {
|
||||
await this.data(() => {
|
||||
const note = this.notes.find(item => item.id === noteId);
|
||||
if (note) note.deleted_time = Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
public async listTags(): Promise<Tag[]> { return this.data(() => structuredClone(this.tags)); }
|
||||
public async getTag(tagId: string): Promise<Tag | null> { return this.data(() => structuredClone(this.tags.find(tag => tag.id === tagId) ?? null)); }
|
||||
public async createTag(input: { title: string }): Promise<Tag> {
|
||||
return this.data(() => {
|
||||
const tag = { id: id(), title: input.title };
|
||||
this.tags.push(tag);
|
||||
return structuredClone(tag);
|
||||
});
|
||||
}
|
||||
public async listNoteTags(): Promise<Tag[]> { return this.data(() => []); }
|
||||
public async listTagNotes(): Promise<Note[]> { return this.data(() => []); }
|
||||
public async addTagToNote(): Promise<void> { await this.data(() => undefined); }
|
||||
public async removeTagFromNote(): Promise<void> { await this.data(() => undefined); }
|
||||
public async search(_query: string, _pagination: Pagination): Promise<Note[]> { return this.data(() => structuredClone(this.notes)); }
|
||||
public async listRevisions(noteId: string): Promise<RevisionRecord[]> {
|
||||
return this.data(() => structuredClone(this.revisions.filter(revision => revision.item_id === noteId)));
|
||||
}
|
||||
public async listChanges(cursor: string | undefined): Promise<ChangePage> {
|
||||
return this.data(() => ({ items: [], cursor: cursor ?? '0', has_more: false }));
|
||||
}
|
||||
|
||||
private async data<T>(operation: () => T): Promise<T> {
|
||||
this.dataCalls += 1;
|
||||
return this.track(operation);
|
||||
}
|
||||
|
||||
private async track<T>(operation: () => T): Promise<T> {
|
||||
this.concurrentCalls += 1;
|
||||
this.maxConcurrentCalls = Math.max(this.maxConcurrentCalls, this.concurrentCalls);
|
||||
try {
|
||||
if (this.delayMs) await new Promise(resolve => setTimeout(resolve, this.delayMs));
|
||||
return operation();
|
||||
} finally {
|
||||
this.concurrentCalls -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import DiffMatchPatch from 'diff-match-patch';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { reconstructRevision } from '../src/domain/revisions.js';
|
||||
import type { RevisionRecord } from '../src/domain/types.js';
|
||||
|
||||
const dmp = new DiffMatchPatch();
|
||||
const patch = (before: string, after: string) => JSON.stringify(dmp.patch_make(before, after));
|
||||
|
||||
describe('revision reconstruction', () => {
|
||||
test('follows Joplin parent revisions and applies text and metadata patches', () => {
|
||||
const first: RevisionRecord = {
|
||||
id: 'r1', parent_id: '', item_id: 'note', item_type: 1, item_updated_time: 10,
|
||||
title_diff: patch('', 'Title one'), body_diff: patch('', 'First body'),
|
||||
metadata_diff: JSON.stringify({ new: { parent_id: 'folder', user_created_time: 1, user_updated_time: 10 }, deleted: [] }),
|
||||
encryption_applied: 0,
|
||||
};
|
||||
const second: RevisionRecord = {
|
||||
id: 'r2', parent_id: 'r1', item_id: 'note', item_type: 1, item_updated_time: 20,
|
||||
title_diff: patch('Title one', 'Title two'), body_diff: patch('First body', 'Second body'),
|
||||
metadata_diff: JSON.stringify({ new: { user_updated_time: 20 }, deleted: [] }),
|
||||
encryption_applied: 0,
|
||||
};
|
||||
expect(reconstructRevision([first, second], 'r2')).toEqual({
|
||||
id: 'r2', note_id: 'note', item_updated_time: 20, title: 'Title two', body: 'Second body',
|
||||
parent_id: 'folder', user_created_time: 1, user_updated_time: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
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<GatewayConfig> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user