Files
immich-folio/lib/__tests__/proofSessions.test.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

199 lines
7.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
claimDownload,
createSession,
deleteSession,
downloadsRemaining,
findSessionByToken,
getSessionById,
isExpired,
isTokenShaped,
listSessions,
newToken,
ProofError,
saveSelection,
sessionInputSchema,
sessionPatchSchema,
sessionState,
submitSelection,
updateSession,
type ProofSession,
} from '../proofSessions';
const ALBUM = '11111111-1111-1111-1111-111111111111';
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proof-'));
vi.spyOn(process, 'cwd').mockReturnValue(dir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(dir, { recursive: true, force: true });
});
const storePath = () => path.join(dir, 'content', 'proofing.json');
function input(overrides: Record<string, unknown> = {}) {
return sessionInputSchema.parse({ clientName: 'Anna', albumId: ALBUM, ...overrides });
}
describe('tokens', () => {
it('are 192-bit URL-safe strings and never repeat', () => {
const a = newToken();
expect(isTokenShaped(a)).toBe(true);
expect(a).not.toBe(newToken());
});
it('rejects anything else before touching the store', async () => {
expect(isTokenShaped('../../etc/passwd')).toBe(false);
expect(isTokenShaped('short')).toBe(false);
expect(await findSessionByToken('short')).toBeNull();
});
});
describe('expiry', () => {
it('is inclusive of the last day', () => {
const session = { expiresOn: '2026-05-12' };
expect(isExpired(session, new Date('2026-05-12T22:00:00'))).toBe(false);
expect(isExpired(session, new Date('2026-05-13T00:00:01'))).toBe(true);
});
it('never expires without a date', () => {
expect(isExpired({}, new Date('2999-01-01'))).toBe(false);
});
});
describe('input validation', () => {
it('requires a client name and an album UUID', () => {
expect(sessionInputSchema.safeParse({ clientName: ' ', albumId: ALBUM }).success).toBe(false);
expect(sessionInputSchema.safeParse({ clientName: 'A', albumId: 'nope' }).success).toBe(false);
});
it('rejects malformed dates and limits', () => {
expect(sessionInputSchema.safeParse({ ...input(), expiresOn: '12.05.2026' }).success).toBe(
false,
);
expect(sessionInputSchema.safeParse({ ...input(), downloadLimit: 0 }).success).toBe(false);
});
it('does not let a patch move a link to another album', () => {
const parsed = sessionPatchSchema.parse({ albumId: ALBUM, clientName: 'B' });
expect(parsed).not.toHaveProperty('albumId');
});
});
describe('store', () => {
it('creates, finds and lists a session, written 0600', async () => {
const session = await createSession(input({ download: 'selection', downloadLimit: 2 }));
expect(session.selection).toEqual([]);
expect(session.downloadsUsed).toBe(0);
expect(await findSessionByToken(session.token)).toMatchObject({ id: session.id });
expect(await getSessionById(session.id)).toMatchObject({ clientName: 'Anna' });
expect(await listSessions()).toHaveLength(1);
if (process.platform !== 'win32') {
expect(fs.statSync(storePath()).mode & 0o777).toBe(0o600);
}
});
it('saves and de-duplicates a selection', async () => {
const { token } = await createSession(input());
const saved = await saveSelection(token, ['a', 'b', 'a']);
expect(saved.selection).toEqual(['a', 'b']);
expect(saved.updatedAt).toBeTruthy();
});
it('serialises concurrent saves instead of losing one', async () => {
const one = await createSession(input({ clientName: 'One' }));
const two = await createSession(input({ clientName: 'Two' }));
await Promise.all([saveSelection(one.token, ['x']), saveSelection(two.token, ['y'])]);
expect((await getSessionById(one.id))?.selection).toEqual(['x']);
expect((await getSessionById(two.id))?.selection).toEqual(['y']);
});
it('locks the selection on submit, and only the first submit counts', async () => {
const { token, id } = await createSession(input());
await saveSelection(token, ['a']);
const first = await submitSelection(token);
const second = await submitSelection(token);
expect(first.firstSubmit).toBe(true);
expect(second.firstSubmit).toBe(false);
await expect(saveSelection(token, ['b'])).rejects.toMatchObject({ code: 'submitted' });
expect(sessionState((await getSessionById(id)) as ProofSession)).toBe('submitted');
// The photographer can reopen it.
await updateSession(id, { reopen: true });
expect((await saveSelection(token, ['b'])).selection).toEqual(['b']);
});
it('refuses every client action on an expired link', async () => {
const { token } = await createSession(input({ expiresOn: '2000-01-01' }));
await expect(saveSelection(token, ['a'])).rejects.toMatchObject({ code: 'expired' });
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'expired' });
});
it('counts downloads against the limit', async () => {
const created = await createSession(input({ download: 'album', downloadLimit: 2 }));
await claimDownload(created.token);
const after = await claimDownload(created.token);
expect(downloadsRemaining(after)).toBe(0);
await expect(claimDownload(created.token)).rejects.toMatchObject({ code: 'limit' });
await updateSession(created.id, { resetDownloads: true });
expect(downloadsRemaining(await claimDownload(created.token))).toBe(1);
});
it('refuses to submit an empty selection, decided under the write lock', async () => {
const { token, id } = await createSession(input());
await saveSelection(token, ['a']);
// A save of [] landing between the route's snapshot and the submit.
await saveSelection(token, []);
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'empty' });
expect((await getSessionById(id))?.submittedAt).toBeUndefined();
});
it('re-checks the download scope under the lock', async () => {
const { token, id } = await createSession(input({ download: 'album' }));
// The photographer narrows the link while a request is on its way.
await updateSession(id, { download: 'selection' });
await expect(claimDownload(token, 'album')).rejects.toMatchObject({ code: 'not-found' });
await expect(claimDownload(token, 'selection')).resolves.toBeTruthy();
});
it('refuses downloads on a link that offers none', async () => {
const { token } = await createSession(input());
await expect(claimDownload(token)).rejects.toBeInstanceOf(ProofError);
});
it('clears optional fields with null in a patch', async () => {
const { id } = await createSession(
input({ expiresOn: '2030-01-01', download: 'album', downloadLimit: 3 }),
);
const patched = await updateSession(id, { expiresOn: null, downloadLimit: null });
expect(patched.expiresOn).toBeUndefined();
expect(patched.downloadLimit).toBeUndefined();
expect(downloadsRemaining(patched)).toBeNull();
});
it('deletes a link, which stops it working', async () => {
const { id, token } = await createSession(input());
await deleteSession(id);
expect(await findSessionByToken(token)).toBeNull();
await expect(deleteSession(id)).rejects.toMatchObject({ code: 'not-found' });
});
it('does not overwrite a store it cannot parse', async () => {
fs.mkdirSync(path.dirname(storePath()), { recursive: true });
fs.writeFileSync(storePath(), '{ not json');
await expect(createSession(input())).rejects.toThrow();
expect(fs.readFileSync(storePath(), 'utf8')).toBe('{ not json');
// …and the queue is not stuck behind the failure.
fs.writeFileSync(storePath(), '{"sessions":[]}');
await expect(createSession(input())).resolves.toBeTruthy();
});
});