Files
picpeak/backend/__tests__/services/transferOwnership.test.js
T
Paul Nothaft 1c242d401f test(transfers): pin the PicTransfer ownership guards (#1006)
Closes #1005.

The two ownership guards added during the #998 review were correct on merge but
untested. They are the only thing between a scoped admin and every other
admin's ORIGINAL files, since a transfer serves those over an unauthenticated
token URL.

14 cases: filterOwnedPhotoIds (own / foreign / ownerless-legacy / mixed /
non-existent / super_admin), addFiles gating on the same rule, listTransfers
scoping plus the absence of token/upload_token/download_url/upload_url from the
list payload, and getTransferOwner.

Each was checked against the pre-fix behaviour rather than only passing against
current code — reverting each guard in turn fails exactly the cases covering it:
ownership filter 3, list scoping 1, payload strip 1, guard registered late 1.

requireTransferOwnership is module-local, so its two contracts are asserted at
the source following the #596 pattern: that router.use('/:id', ...) precedes
every /:id route — ordering is the whole mechanism, and a late registration
would guard nothing while still looking present — and that missing and foreign
ids both answer 404, so the endpoint is not an existence oracle.

Tests only; no production code touched.
2026-08-10 08:10:54 +02:00

248 lines
10 KiB
JavaScript

/**
* Ownership guards for PicTransfer (#998 review, tracked as #1005).
*
* A transfer bundles ORIGINAL files and hands them out over an unauthenticated
* token URL, so the two guards below are the only thing standing between a
* scoped admin and every other admin's originals:
*
* 1. filterOwnedPhotoIds — a scoped admin may only bundle photos from events
* they own. Without it, arbitrary photo ids in the create/add-files body
* became a public download link to anyone's originals.
* 2. listTransfers scoping + payload stripping — the list used to be unscoped
* AND to carry each row's download token, so any admin holding events.view
* could read another's token and fetch their originals without creating
* anything at all.
*
* Both were correct when merged. These tests exist so they stay that way: an
* untested guard does not survive refactoring, which #999 demonstrated when the
* same attribution fix landed in one component and was left stale in another.
* Each case below fails against the pre-fix behaviour, not merely passes
* against the current code.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transferown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transferown-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('PicTransfer ownership guards (#998)', () => {
let db; let cleanup; let transferService;
let editorA; let editorB; let superAdmin;
let eventA; let eventB; let eventOwnerless;
let photoA; let photoB; let photoOwnerless;
const asEditor = (id) => ({ id, roleName: 'editor' });
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, filename) => {
const r = await db('photos').insert({
event_id: eventId, filename, path: `events/${eventId}/${filename}`,
type: 'individual', uploaded_at: Date.now(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkTransfer = async (title, createdBy) => {
const r = await db('transfers').insert({
token: `tok-${title}-${'0'.repeat(50)}`.slice(0, 64),
title, created_by: createdBy,
expires_at: new Date(Date.now() + 864e5).toISOString(),
download_count: 0, is_active: 1, grace_days: 7, allow_uploads: 0,
delivery_method: 'link',
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
transferService = require('../../src/services/transferService');
editorA = await mkAdmin('xfer-a', 'editor');
editorB = await mkAdmin('xfer-b', 'editor');
superAdmin = await mkAdmin('xfer-root', 'super_admin');
eventA = await mkEvent('xfer-own', editorA);
eventB = await mkEvent('xfer-foreign', editorB);
eventOwnerless = await mkEvent('xfer-legacy', null);
photoA = await mkPhoto(eventA, 'own.jpg');
photoB = await mkPhoto(eventB, 'foreign.jpg');
photoOwnerless = await mkPhoto(eventOwnerless, 'legacy.jpg');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('filterOwnedPhotoIds', () => {
it("drops photos from another admin's event", async () => {
// The exfiltration path: these ids would otherwise be bundled into a
// transfer and served over the public download token.
const owned = await transferService.filterOwnedPhotoIds(asEditor(editorA), [photoB]);
expect(owned).toEqual([]);
});
it("keeps photos from the caller's own event", async () => {
const owned = await transferService.filterOwnedPhotoIds(asEditor(editorA), [photoA]);
expect(owned).toEqual([photoA]);
});
it('keeps photos from an ownerless legacy event', async () => {
// Parity with filterOwnedEventIds, which treats created_by IS NULL as
// ownable by anyone — otherwise legacy events become unusable.
const owned = await transferService.filterOwnedPhotoIds(asEditor(editorA), [photoOwnerless]);
expect(owned).toEqual([photoOwnerless]);
});
it('keeps only the owned subset of a mixed request', async () => {
const owned = await transferService.filterOwnedPhotoIds(
asEditor(editorA), [photoA, photoB, photoOwnerless],
);
expect(owned.sort()).toEqual([photoA, photoOwnerless].sort());
expect(owned).not.toContain(photoB);
});
it('drops ids that do not exist', async () => {
const owned = await transferService.filterOwnedPhotoIds(asEditor(editorA), [999999]);
expect(owned).toEqual([]);
});
it('leaves super_admin unrestricted', async () => {
const owned = await transferService.filterOwnedPhotoIds(
{ id: superAdmin, roleName: 'super_admin' }, [photoA, photoB, photoOwnerless],
);
expect(owned.sort()).toEqual([photoA, photoB, photoOwnerless].sort());
});
});
describe('addFiles gates on the same rule', () => {
it("refuses to attach another admin's photo", async () => {
// The guard has to sit in addFiles, not only at the route, because both
// createTransfer and POST /:id/files funnel through it.
const transferId = await mkTransfer('gate', editorA);
await transferService.addFiles(transferId, [photoA, photoB], asEditor(editorA));
const attached = await db('transfer_files')
.where({ transfer_id: transferId }).pluck('photo_id');
expect(attached).toContain(photoA);
expect(attached).not.toContain(photoB);
});
});
describe('listTransfers', () => {
let mineId; let theirsId;
beforeAll(async () => {
mineId = await mkTransfer('mine', editorA);
theirsId = await mkTransfer('theirs', editorB);
});
it("hides another admin's transfers from a scoped caller", async () => {
const rows = await transferService.listTransfers({ admin: asEditor(editorA) });
const ids = rows.map((r) => r.id);
expect(ids).toContain(mineId);
expect(ids).not.toContain(theirsId);
});
it('shows everything to super_admin', async () => {
const rows = await transferService.listTransfers({
admin: { id: superAdmin, roleName: 'super_admin' },
});
const ids = rows.map((r) => r.id);
expect(ids).toEqual(expect.arrayContaining([mineId, theirsId]));
});
it('never carries download or upload links in the list payload', async () => {
// Defence in depth on top of the scoping above, and the layer most likely
// to be undone by a "the list needs the link too" change. The token is a
// bearer credential for the originals — detail only.
const rows = await transferService.listTransfers({ admin: asEditor(editorA) });
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
expect(row).not.toHaveProperty('token');
expect(row).not.toHaveProperty('upload_token');
expect(row).not.toHaveProperty('download_url');
expect(row).not.toHaveProperty('upload_url');
}
});
});
// The guard is a module-local middleware, so rather than stand up supertest
// just to prove Express ordering, assert the contract at the source — the
// same approach taken for the backup/restore contracts in #596. Ordering is
// the whole mechanism here: `router.use('/:id', …)` registered after the
// `/:id` routes would silently guard nothing while still looking present.
describe('requireTransferOwnership registration', () => {
const routerSrc = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminTransfers.js'), 'utf8',
);
it('mounts the ownership guard before every /:id route', () => {
const guardAt = routerSrc.indexOf("router.use('/:id', requireTransferOwnership)");
expect(guardAt).toBeGreaterThan(-1);
const idRoutes = [...routerSrc.matchAll(/^router\.(get|post|patch|delete)\('\/:id/gm)];
expect(idRoutes.length).toBeGreaterThan(0);
for (const m of idRoutes) {
expect(m.index).toBeGreaterThan(guardAt);
}
});
it('answers missing and foreign ids identically, so it is not an existence oracle', () => {
const guard = routerSrc.slice(
routerSrc.indexOf('async function requireTransferOwnership'),
routerSrc.indexOf('// List'),
);
// Both branches must 404. A 403 on foreign would confirm the row exists.
const notFounds = [...guard.matchAll(/status\(404\)/g)];
expect(notFounds.length).toBeGreaterThanOrEqual(2);
expect(guard).not.toMatch(/status\(403\)/);
expect(guard).toMatch(/roleName === 'super_admin'/);
});
});
describe('getTransferOwner (backs requireTransferOwnership)', () => {
it('reports the creator so the route guard can compare it', async () => {
const id = await mkTransfer('owned-lookup', editorB);
const owner = await transferService.getTransferOwner(id);
expect(Number(owner.created_by)).toBe(Number(editorB));
});
it('returns nothing for a missing id, so the guard 404s rather than throwing', async () => {
const owner = await transferService.getTransferOwner(999999);
expect(owner).toBeFalsy();
});
});
});