mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
143 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|