/**
* Integration tests for the note image routes.
*
* The route reads the request body as a bounded stream rather than buffering
* it, sniffs the type from the bytes instead of trusting the client, and stores
* a path relative to the data dir. Each of those is load-bearing and silent
* when broken — a buffered body only shows up as memory growth under a hostile
* client, and an absolute path only shows up after the data dir moves — so they
* are pinned here.
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { notesImagesRoute } from './notes-images';
import { authQueries } from '../database/queries';
import { hashToken } from '../auth/tokens';
import { projectQueries } from '../database/queries/project-queries';
import { noteQueries, noteCollectionQueries, noteImageQueries } from '../database/queries/note-queries';
import { initializeDatabase, closeDatabase } from '../database';
import { getCollectionNotesDir, resolveNoteImagePath } from '../notes/storage';
// Smallest valid GIF89a — `file-type` recognises it from the header alone.
const GIF_BYTES = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff,
0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b
]);
let userId: string;
let token: string;
let projectId: string;
let collectionId: string;
let noteId: string;
function uploadRequest(body: BodyInit, fileName: string, declaredSize: number, bearer = token): Request {
const url = new URL('http://localhost/api/notes/images/upload');
url.searchParams.set('noteId', noteId);
url.searchParams.set('fileName', fileName);
url.searchParams.set('fileSize', String(declaredSize));
return new Request(url.toString(), {
method: 'POST',
body,
headers: { authorization: `Bearer ${bearer}` }
});
}
beforeAll(async () => {
await initializeDatabase();
userId = randomUUID();
authQueries.createUser({
id: userId,
name: 'Notes Test User',
color: '#000000',
avatar: 'test',
role: 'admin',
personal_access_token_hash: null,
created_at: new Date().toISOString()
});
token = randomUUID();
authQueries.createSession({
id: randomUUID(),
user_id: userId,
token_hash: hashToken(token),
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
created_at: new Date().toISOString(),
last_active_at: new Date().toISOString(),
user_agent: null,
ip_address: null,
source: null
});
const project = projectQueries.create({
name: 'Notes Test Project',
path: `/tmp/notes-test-project-${randomUUID()}`,
created_at: new Date().toISOString(),
last_opened_at: new Date().toISOString()
});
projectId = project.id;
projectQueries.addUserProject(userId, projectId);
collectionId = noteCollectionQueries.create({
name: 'General',
scope: 'project',
projectId,
createdBy: userId
}).id;
noteId = noteQueries.create({ collectionId, content: 'body', createdBy: userId }).id;
});
afterAll(async () => {
await rm(getCollectionNotesDir(collectionId), { recursive: true, force: true });
authQueries.deleteSessionsByUserId(userId);
authQueries.deleteUser(userId);
projectQueries.deleteProject(projectId);
closeDatabase();
});
describe('POST /api/notes/images/upload', () => {
it('stores an accepted image at a path relative to the data dir', async () => {
const res = await notesImagesRoute.handle(
uploadRequest(GIF_BYTES, 'holiday photo.gif', GIF_BYTES.length)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { image: { id: string; storage_path: string; mime_type: string } };
expect(body.image.mime_type).toBe('image/gif');
// Relative + POSIX: an absolute path here would break the moment
// CLOPEN_DATA_DIR changes or the data dir is copied to another machine.
// The space in the uploaded name is sanitized away.
expect(body.image.storage_path).toMatch(
new RegExp(`^notes/${collectionId}/${noteId}/[0-9a-f-]{36}-holiday_photo\\.gif$`)
);
const absolute = resolveNoteImagePath(body.image.storage_path);
expect(absolute).not.toBeNull();
expect(await Bun.file(absolute!).exists()).toBe(true);
});
it('rejects a non-image announced with an image file name, and keeps no file', async () => {
const before = noteImageQueries.listByNote(noteId).length;
const payload = new TextEncoder().encode(''.padEnd(5000, ' '));
const res = await notesImagesRoute.handle(uploadRequest(payload, 'payload.png', payload.length));
expect(res.status).toBe(400);
expect(await res.text()).toContain('Unsupported image type');
expect(noteImageQueries.listByNote(noteId).length).toBe(before);
});
it('stops reading once a body exceeds the size it declared', async () => {
// A client that under-declares its size is how an unbounded read turns
// into "one project member decides this process's memory ceiling".
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(64 * 1024));
controller.enqueue(new Uint8Array(64 * 1024));
controller.close();
}
});
const res = await notesImagesRoute.handle(uploadRequest(stream, 'big.png', 1024));
expect(res.status).toBe(400);
expect(await res.text()).toContain('more bytes than declared');
});
it('rejects a declared size over the 10 MB cap before reading anything', async () => {
const res = await notesImagesRoute.handle(uploadRequest(GIF_BYTES, 'huge.gif', 11 * 1024 * 1024));
expect(res.status).toBe(413);
});
it('requires a bearer token', async () => {
const url = new URL('http://localhost/api/notes/images/upload');
url.searchParams.set('noteId', noteId);
url.searchParams.set('fileSize', String(GIF_BYTES.length));
const res = await notesImagesRoute.handle(
new Request(url.toString(), { method: 'POST', body: GIF_BYTES })
);
expect(res.status).toBe(401);
});
});
describe('GET /api/notes/images/:id', () => {
it('serves the stored bytes to a project member', async () => {
const upload = await notesImagesRoute.handle(uploadRequest(GIF_BYTES, 'served.gif', GIF_BYTES.length));
const { image } = (await upload.json()) as { image: { id: string } };
const res = await notesImagesRoute.handle(
new Request(`http://localhost/api/notes/images/${image.id}`, {
headers: { authorization: `Bearer ${token}` }
})
);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/gif');
expect(new Uint8Array(await res.arrayBuffer())).toEqual(GIF_BYTES);
});
it('does not accept the session token from the query string', async () => {
const upload = await notesImagesRoute.handle(uploadRequest(GIF_BYTES, 'query.gif', GIF_BYTES.length));
const { image } = (await upload.json()) as { image: { id: string } };
// A credential in a URL ends up in browser history, proxy logs and tunnel
// edge logs, so the route must not honour one.
const res = await notesImagesRoute.handle(
new Request(`http://localhost/api/notes/images/${image.id}?token=${token}`)
);
expect(res.status).toBe(401);
});
it('denies a user who is not a member of the project the collection belongs to', async () => {
const outsiderId = randomUUID();
authQueries.createUser({
id: outsiderId,
name: 'Outsider',
color: '#000000',
avatar: 'test',
role: 'member',
personal_access_token_hash: null,
created_at: new Date().toISOString()
});
const outsiderToken = randomUUID();
authQueries.createSession({
id: randomUUID(),
user_id: outsiderId,
token_hash: hashToken(outsiderToken),
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
created_at: new Date().toISOString(),
last_active_at: new Date().toISOString(),
user_agent: null,
ip_address: null,
source: null
});
const upload = await notesImagesRoute.handle(uploadRequest(GIF_BYTES, 'private.gif', GIF_BYTES.length));
const { image } = (await upload.json()) as { image: { id: string } };
const res = await notesImagesRoute.handle(
new Request(`http://localhost/api/notes/images/${image.id}`, {
headers: { authorization: `Bearer ${outsiderToken}` }
})
);
expect(res.status).toBe(403);
authQueries.deleteSessionsByUserId(outsiderId);
authQueries.deleteUser(outsiderId);
});
});