Files
picpeak/backend/__tests__/routes/galleryAccessPolicy.test.js
T
Paul Nothaft f0e6d2dfb1 fix: enforce gallery access and consolidate gallery workflows (#1357)
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
2026-09-08 15:34:09 +02:00

204 lines
12 KiB
JavaScript

/** Real routes + migrated SQLite: the same session policy protects lists and media. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const fs = require('fs/promises');
const path = require('path');
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({}),
}));
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
const eventId = 70001, photoId = 70002, slug = 'policy-test';
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
const get = (url, bearer) => {
const req = request(app).get(url);
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
};
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
const expectDirect = async (bearer, status, suffix = '') => {
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id');
foreignId = row.id ?? row;
await assignAdminRole(db, foreignId, 'viewer');
await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test',
event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused',
share_link: '/gallery/policy-test', created_by: adminId });
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
await fs.mkdir(path.dirname(file), { recursive: true });
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
secure = require('../../src/services/secureImageService');
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
cutoff = require('../../src/utils/sessionCutoff');
({ revokeToken } = require('../../src/utils/tokenRevocation'));
app = express(); app.use(express.json()); app.use(cookieParser());
app.use('/api', require('../../src/middleware/csrf'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
beforeEach(async () => {
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
}
await cutoff.setSessionsValidAfter(0);
event = await db('events').where({ id: eventId }).first();
});
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
it('serves a valid session as a real list and JPEG', async () => {
const bearer = token();
const list = await get(endpoints[0], bearer);
expect(list.status).toBe(200);
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
const image = await get(endpoints[1], bearer);
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
expect(image.body.length).toBeGreaterThan(100);
});
it('scopes draft previews to the owner and current read permissions', async () => {
await db('events').where({ id: eventId }).update({ is_draft: 1 });
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
// Ownership alone does not grant a user without a role read access.
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
await db('admin_users').where({ id: adminId }).update({ role_id: null });
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
});
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
const bearer = token();
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
await expectDirect(bearer, 401);
});
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
const bearer = token({ via: 'customer', customerId });
await expectDirect(bearer, 200);
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
await expectDirect(bearer, 401);
});
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
const expiry = Date.now() - 1000;
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
await expectDirect(undefined, 404); await expectDirect(token(), 404);
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
});
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
expect(signed.status).toBe(200);
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
expect(minted.status).toBe(200);
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
expect((await get(signed.body.url)).status).toBe(200);
expect((await get(secureUrl)).status).toBe(200);
if (reason === 'revocation') await revokeToken(bearer, 'test');
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
const status = reason === 'expiry' ? 404 : 401;
expect((await get(signed.body.url)).status).toBe(status);
expect((await get(secureUrl)).status).toBe(status);
});
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
const cookie = `admin_token=${mintAdminToken(adminId)}`;
const url = `/api/admin/events/${eventId}/reveal`;
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
expect(blocked.status).toBe(403);
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
process.env.ADMIN_URL = 'https://admin.example.test';
try {
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
.set('Sec-Fetch-Site', 'cross-site').send({});
expect(allowed.status).toBe(200);
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
} finally { delete process.env.ADMIN_URL; }
});
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
expect(response.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect([false, 0]).toContain(row.is_active);
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
});
it('paginates after feedback filtering, with a total independent of page size', async () => {
const ids = [70003, 70004, 70005];
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
type: 'individual', like_count: 1, processing_status: 'complete' })));
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
try {
const bearer = token();
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
expect(first.status).toBe(200); expect(second.status).toBe(200);
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
} finally {
await db('photos').whereIn('id', ids).del();
await db('event_feedback_settings').where({ event_id: eventId }).del();
}
});
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
const bearer = token({ via: 'customer', customerId });
await expectDirect(bearer, 200);
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
});
it('denies foreign editors and allows the editor who owns the gallery', async () => {
await assignAdminRole(db, foreignId, 'editor');
try {
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
await db('events').where({ id: eventId }).update({ created_by: foreignId });
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
} finally {
await db('events').where({ id: eventId }).update({ created_by: adminId });
await assignAdminRole(db, foreignId, 'viewer');
}
});
it('bounds a large gallery response while retaining the complete count', async () => {
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
try {
await db.batchInsert('photos', rows, 100);
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
expect(response.status).toBe(200);
expect(response.body.photos).toHaveLength(250);
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
});