Files
TREK/server/tests/unit/plugins/journal-photo.rpc.test.ts
T
2026-10-06 21:50:59 +02:00

143 lines
6.4 KiB
TypeScript

/**
* 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<string, unknown> = {}) => ({ 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();
});
});