/** * journal.addEntryPhoto — the byte path a journal importer needs (#1365). * * A plugin holding an export archive has bytes and nothing else: no gallery photo * to link and no provider asset. These cases pin what the handler refuses before * it writes anything, and that a refused write leaves no object behind. */ import { JournalRpc } from '../../../src/nest/journey/journal.rpc'; import { describe, it, expect, vi } from 'vitest'; const ACTOR = { actingUserId: 7 } as never; const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64'); function build( overrides: { addPhoto?: unknown; allowed?: string; /** SV8 (Plan 3i, R-survivors) — JournalRpc now delegates the whole demo-mode * check (env-enabled + email match) to `DemoService.isDemoUserId`, so the * test double only needs to say whether that call refuses. */ demoBlocked?: boolean; } = {}, ) { // Typed parameters so the assertions below can index mock.calls. const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined); const del = vi.fn(async (_category: string, _filename: string) => undefined); const scheduleUpload = vi.fn(); const addPhoto = vi.fn(overrides.addPhoto === undefined ? () => ({ id: 5, photo_id: 42 }) : () => overrides.addPhoto); const rpc = new JournalRpc( { addPhoto } as never, { requireAddon: vi.fn() } as never, { put, delete: del } as never, { get: () => overrides.allowed ?? '*' } as never, { scheduleUpload } as never, // SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive. { isDemoUserId: async () => overrides.demoBlocked ?? false } as never, ); return { rpc, put, del, scheduleUpload, addPhoto }; } const input = (over: Record = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over }); describe('journal.addEntryPhoto', () => { it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => { const { rpc, put, scheduleUpload, addPhoto } = build(); const photo = await rpc.addEntryPhoto({ entryId: 3, input: input({ caption: 'Tokyo' }) }, ACTOR); expect(photo).toEqual({ id: 5, photo_id: 42 }); // Category and a generated filename: never the name the plugin sent, which // could otherwise collide with an existing gallery row on file_path. expect(put).toHaveBeenCalledTimes(1); const [category, filename] = put.mock.calls[0]; expect(category).toBe('journey'); expect(filename).toMatch(/^[0-9a-f-]{36}\.jpg$/); expect(addPhoto).toHaveBeenCalledWith(3, 7, `journey/${filename}`, undefined, 'Tokyo'); // An upload's capture time is backfilled like the REST upload's, without the // journey refresh: one photo per call would be one reload per photo (#1587). expect(scheduleUpload).toHaveBeenCalledWith([{ id: 5, photo_id: 42 }], 7); }); it('JPHOTO-002: refuses an entry the acting user cannot edit, and removes the object it just wrote', async () => { // addPhoto answering null IS the access check: the entry is missing, or it // belongs to a journey this user may not edit. const { rpc, put, del } = build({ addPhoto: null }); await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow( /no editable journal entry 3/, ); expect(put).toHaveBeenCalledTimes(1); expect(del).toHaveBeenCalledWith('journey', put.mock.calls[0][1]); }); it('JPHOTO-003: refuses a name that is a path, and an extension that is not an image', async () => { const { rpc, put } = build(); // basename() reduces this to 'evil.sh', which is not an image extension. await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ name: '../../evil.sh' }) }, ACTOR)).rejects.toThrow( /not an allowed image type/, ); await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ name: 'map.svg' }) }, ACTOR)).rejects.toThrow( /not an allowed image type/, ); await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ name: 'noext' }) }, ACTOR)).rejects.toThrow( /not an allowed image type/, ); expect(put).not.toHaveBeenCalled(); }); it('JPHOTO-004: obeys the operator allowed-file-types setting, so the RPC is no way around it', async () => { const { rpc, put } = build({ allowed: 'png,pdf' }); await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ name: 'photo.jpg' }) }, ACTOR)).rejects.toThrow( /file type \.jpg is not allowed/, ); expect(put).not.toHaveBeenCalled(); await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ name: 'photo.png' }) }, ACTOR)).resolves.toBeTruthy(); }); it('JPHOTO-005: rejects an oversized payload before decoding it, and an empty one after', async () => { const { rpc, put } = build(); // Encoded cap first: a 15MB string never becomes a 11MB buffer in memory. await expect( rpc.addEntryPhoto({ entryId: 3, input: input({ content_base64: 'A'.repeat(15 * 1024 * 1024) }) }, ACTOR), ).rejects.toThrow(/invalid photo input/); await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ content_base64: '@@@@' }) }, ACTOR)).rejects.toThrow( /photo content is empty/, ); expect(put).not.toHaveBeenCalled(); }); it('JPHOTO-006: rejects an entryId that is not a positive integer, and unknown input keys', async () => { const { rpc } = build(); await expect(rpc.addEntryPhoto({ entryId: 1.5, input: input() }, ACTOR)).rejects.toThrow(/positive integer/); await expect(rpc.addEntryPhoto({ entryId: 0, input: input() }, ACTOR)).rejects.toThrow(/positive integer/); // strictObject: an unknown key is a mistake worth reporting, not ignoring. await expect(rpc.addEntryPhoto({ entryId: 3, input: input({ provider: 'immich' }) }, ACTOR)).rejects.toThrow( /invalid photo input/, ); }); it('JPHOTO-007: refuses to write bytes for a demo user, exactly as the REST upload does', async () => { const { rpc, put } = build({ demoBlocked: true }); await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow(/disabled in demo mode/); expect(put).not.toHaveBeenCalled(); }); it('JPHOTO-008: a userless call never reaches storage', async () => { const { rpc, put } = build(); await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, {} as never)).rejects.toThrow( /journal writes require an authenticated user context/, ); expect(put).not.toHaveBeenCalled(); }); });