- registerSchemesAsPrivileged: inkling-media 스킴을 secure + supportFetchAPI + stream 으로 등록 (whenReady 이전 호출 필수). - registerInklingMediaProtocol: profileDir/media 하위 파일을 raw URL traversal (.., %2e%2e) 검사 + normalize 후 mediaRoot 봉쇄로 이중 검증 후 readFile. - inferMime: png/jpg/jpeg/gif/webp → image/*, 그 외 → application/octet-stream. - src/main/index.ts: 모듈 import 직후 registerSchemesAsPrivileged(), whenReady 안 paths 결정 직후 registerInklingMediaProtocol(paths.profileDir). - tests/unit/inklingMedia.test.ts: 8 unit (5 inferMime + 3 handler — valid/403/404). vitest 의 new Request() 가 url 을 normalize 하므로 raw url 보존을 위해 minimal mock req 사용.
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { join, sep } from 'node:path';
|
|
|
|
const { mockReadFile, mockHandle, mockRegisterSchemes } = vi.hoisted(() => ({
|
|
mockReadFile: vi.fn(),
|
|
mockHandle: vi.fn(),
|
|
mockRegisterSchemes: vi.fn()
|
|
}));
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
readFile: mockReadFile
|
|
}));
|
|
|
|
vi.mock('electron', () => ({
|
|
default: {
|
|
protocol: {
|
|
registerSchemesAsPrivileged: mockRegisterSchemes,
|
|
handle: mockHandle
|
|
}
|
|
}
|
|
}));
|
|
|
|
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
|
|
|
|
describe('inferMime', () => {
|
|
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
|
|
it('returns image/jpeg for .jpg and .jpeg', () => {
|
|
expect(inferMime('foo.jpg')).toBe('image/jpeg');
|
|
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
|
|
});
|
|
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
|
|
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
|
|
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
|
|
});
|
|
|
|
describe('inkling-media protocol handler', () => {
|
|
beforeEach(() => { vi.clearAllMocks(); });
|
|
|
|
function getHandler(profileDir: string): (req: { url: string }) => Promise<Response> {
|
|
registerInklingMediaProtocol(profileDir);
|
|
const call = mockHandle.mock.calls[0];
|
|
if (!call) throw new Error('protocol.handle not called');
|
|
return call[1] as (req: { url: string }) => Promise<Response>;
|
|
}
|
|
|
|
// 실 운영 (Electron protocol.handle) 에서는 req.url 이 raw 문자열로 전달되지만,
|
|
// vitest 의 `new Request()` constructor 는 url 을 즉시 normalize (`/../` 제거) 함.
|
|
// 따라서 traversal 검사 로직이 raw URL 단계에서 작동하는지 검증하려면
|
|
// raw url 을 보존한 minimal mock 을 직접 전달.
|
|
function rawReq(url: string): { url: string } { return { url }; }
|
|
|
|
it('serves valid file with correct mime', async () => {
|
|
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
|
|
const handler = getHandler('/profile');
|
|
const res = await handler(rawReq('inkling-media://media/note1/img.png'));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('image/png');
|
|
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
|
});
|
|
|
|
it('returns 403 on path traversal attempt', async () => {
|
|
const handler = getHandler('/profile');
|
|
const res = await handler(rawReq('inkling-media://media/../etc/passwd'));
|
|
expect(res.status).toBe(403);
|
|
expect(mockReadFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 404 when file missing', async () => {
|
|
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
|
const handler = getHandler('/profile');
|
|
const res = await handler(rawReq('inkling-media://media/note1/missing.png'));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|