mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
130 lines
6.1 KiB
TypeScript
130 lines
6.1 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 { describe, it, expect, vi } from 'vitest';
|
|
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
|
|
|
|
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 schedule = 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,
|
|
{ schedule } as never,
|
|
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
|
|
{ isDemoUserId: async () => overrides.demoBlocked ?? false } as never,
|
|
);
|
|
return { rpc, put, del, schedule, 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, schedule, 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');
|
|
expect(schedule).toHaveBeenCalledWith([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();
|
|
});
|
|
});
|