Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@
"jest": {
"coverageThreshold": {
"global": {
"statements": 84,
"branches": 70,
"functions": 90,
"lines": 85
"statements": 90,
"branches": 76,
"functions": 91,
"lines": 90
}
}
},
Expand Down
128 changes: 128 additions & 0 deletions backend/tests/unit/auth.controller.errors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Error-path battery for the auth controller: the anti-enumeration behaviors
* (duplicate email indistinguishable from success shape, generic 500s) and
* the clean mapping of unexpected failures on every endpoint — the raw
* database error must never reach a client.
*/
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret';
process.env.TOTP_ENCRYPTION_KEY =
process.env.TOTP_ENCRYPTION_KEY ||
'20f766230f5b4740f5b620d2dde09488b110435c13395edb10e1fdcd5ddf2098';
process.env.MAIL_HOST = process.env.MAIL_HOST || 'smtp.test.local';
process.env.MAIL_PORT = process.env.MAIL_PORT || '465';
process.env.MAIL_SECURE = process.env.MAIL_SECURE || 'true';
process.env.MAIL_USER = process.env.MAIL_USER || 'mail@test.local';
process.env.MAIL_PASS = process.env.MAIL_PASS || 'test_mail_password';

jest.mock('../../src/services/mail.service');
jest.mock('../../src/database');
jest.mock('../../src/services/token.service');

const authController = require('../../src/controllers/auth.controller');
const db = require('../../src/database');
const mailService = require('../../src/services/mail.service');

const makeRes = () => ({
json: jest.fn(),
status: jest.fn().mockReturnThis(),
cookie: jest.fn(),
clearCookie: jest.fn(),
});

describe('auth controller error paths', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe('register', () => {
const body = { name: 'Axelle', email: 'axelle@example.com', password: 'Sup3rSecret!' };

it('answers a duplicate email with a GENERIC message (no account enumeration)', async () => {
const dup = new Error('dup');
dup.code = 'ER_DUP_ENTRY';
db.query.mockRejectedValueOnce(dup);
const res = makeRes();

await authController.register({ body, id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(400);
const payload = res.json.mock.calls.at(-1)[0];
expect(payload.error).not.toMatch(/already|exist|taken|duplicate/i);
});

it('still succeeds when the verification mail fails to send (resend covers it)', async () => {
db.query.mockResolvedValueOnce([{ insertId: 7 }]);
mailService.sendMail.mockRejectedValueOnce(new Error('smtp down'));
const res = makeRes();

await authController.register({ body, id: 'req-1' }, res);
await new Promise((resolve) => setImmediate(resolve));

expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
});

it('maps an unexpected failure to a generic 500', async () => {
db.query.mockRejectedValueOnce(new Error('connection lost'));
const res = makeRes();

await authController.register({ body, id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Server error.' });
});
});

// Endpoints whose unexpected-failure contract is a clean 500 with no detail.
const FIVE_HUNDREDS = [
['login', { body: { email: 'a@b.com', password: 'Password1' } }],
['verify', { body: { email: 'a@b.com', code: '123456' } }],
['resendCode', { body: { email: 'a@b.com' } }],
['forgotPassword', { body: { email: 'a@b.com' } }],
['resetPassword', { body: { email: 'a@b.com', code: '123456', newPassword: 'N3wPassword!' } }],
];

describe.each(FIVE_HUNDREDS)('%s', (name, baseReq) => {
it('maps an unexpected database failure to a clean 500', async () => {
db.query.mockRejectedValue(new Error('connection lost'));
const res = makeRes();

await authController[name]({ ...baseReq, id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(500);
const payload = res.json.mock.calls.at(-1)[0];
expect(JSON.stringify(payload)).not.toContain('connection lost');
});
});

describe('refresh', () => {
it('rejects a request with no refresh cookie at all', async () => {
const res = makeRes();
await authController.refresh({ headers: {}, id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Missing refresh token.' });
});
});

describe('demoLogin', () => {
it('answers 503 when no demo account is seeded', async () => {
db.query.mockResolvedValueOnce([[]]);
const res = makeRes();

await authController.demoLogin({ id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(503);
});

it('maps an unexpected failure to a clean 500', async () => {
db.query.mockRejectedValueOnce(new Error('connection lost'));
const res = makeRes();

await authController.demoLogin({ id: 'req-1' }, res);

expect(res.status).toHaveBeenCalledWith(500);
});
});
});
92 changes: 92 additions & 0 deletions backend/tests/unit/projects.controller.errors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Systematic error-path battery for the projects controller: every handler
* must (1) reject an unauthenticated request with 401 before touching the
* database, and (2) map an unexpected database failure to a clean 500 —
* never leak the raw error. Table-driven so a future handler added to the
* controller without these guarantees fails loudly here.
*/
const projectsController = require('../../src/controllers/projects.controller');
const db = require('../../src/database');

jest.mock('../../src/database');

const makeRes = () => ({
json: jest.fn(),
status: jest.fn().mockReturnThis(),
set: jest.fn(),
send: jest.fn(),
});

// Handler name -> a request shaped for it (no `user` — added per test).
const AUTHENTICATED_HANDLERS = {
listProjects: { query: {} },
getProject: { params: { id: '1' } },
searchProjects: { query: { q: 'x' } },
createProject: { body: { name: 'Projet' } },
duplicateProject: { params: { id: '1' } },
pinProject: { params: { id: '1' } },
unpinProject: { params: { id: '1' } },
reorderPinnedProjects: { body: [1] },
enableSharing: { params: { id: '1' } },
disableSharing: { params: { id: '1' } },
updateProjectName: { params: { id: '1' }, body: { name: 'New name' } },
deleteProject: { params: { id: '1' } },
listTrashedProjects: { query: {} },
restoreProject: { params: { id: '1' } },
deleteProjectPermanently: { params: { id: '1' } },
addBrushNorm: { params: { id: '1' }, body: { name: 'Line', value: '8', unit: 'px' } },
addTypographyNorm: {
params: { id: '1' },
body: { fontFamily: 'Figtree', fontWeight: '400', fontUsage: 'Body' },
},
updatePalette: { params: { id: '1' }, body: [{ name: 'Ink', hex: '#112233' }] },
reorderBrushNorms: { params: { id: '1' }, body: { orderedIds: [1] } },
reorderTypographyNorms: { params: { id: '1' }, body: { orderedIds: [1] } },
deleteBrushNorm: { params: { id: '1', normId: '2' } },
deleteTypographyNorm: { params: { id: '1', normId: '2' } },
listTrashedBrushNorms: { params: { id: '1' } },
restoreBrushNorm: { params: { id: '1', normId: '2' } },
deleteBrushNormPermanently: { params: { id: '1', normId: '2' } },
listTrashedTypographyNorms: { params: { id: '1' } },
restoreTypographyNorm: { params: { id: '1', normId: '2' } },
deleteTypographyNormPermanently: { params: { id: '1', normId: '2' } },
deletePaletteColor: { params: { id: '1', colorId: '2' } },
listTrashedPaletteColors: { params: { id: '1' } },
restorePaletteColor: { params: { id: '1', colorId: '2' } },
deletePaletteColorPermanently: { params: { id: '1', colorId: '2' } },
updateBrushNorm: { params: { id: '1', normId: '2' }, body: { name: 'L', value: '9' } },
updateTypographyNorm: {
params: { id: '1', normId: '2' },
body: { fontFamily: 'Figtree', fontWeight: '400', fontUsage: 'Body' },
},
};

describe('projects controller error paths (table-driven)', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe.each(Object.entries(AUTHENTICATED_HANDLERS))('%s', (name, baseReq) => {
it('rejects an unauthenticated request with 401 without touching the database', async () => {
const res = makeRes();
await projectsController[name]({ ...baseReq }, res);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'User not authenticated.' });
expect(db.query).not.toHaveBeenCalled();
});

it('maps an unexpected database failure to a clean 500', async () => {
db.query.mockRejectedValue(new Error('connection lost'));
db.getConnection.mockRejectedValue(new Error('connection lost'));
const res = makeRes();
await projectsController[name]({ ...baseReq, user: { id: 1 } }, res);

expect(res.status).toHaveBeenCalledWith(500);
// Whatever the handler's wording, the raw driver error never leaks.
const payload = res.json.mock.calls.at(-1)[0];
expect(payload.error).toMatch(/error/i);
expect(JSON.stringify(payload)).not.toContain('connection lost');
});
});
});
128 changes: 128 additions & 0 deletions backend/tests/unit/projects.service.validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* The projects service's input validators — every rejection branch is a
* user-facing 400 and a DB-integrity guarantee (nothing over-long, no alpha
* hex channels the exporters can't handle, no forged ids).
*/
const projectsService = require('../../src/services/projects.service');

jest.mock('../../src/database');

describe('validateProjectName', () => {
it('trims and accepts a normal name', () => {
expect(projectsService.validateProjectName(' Alyse ')).toBe('Alyse');
});

it('rejects a missing/blank name with missing_name', () => {
for (const bad of [undefined, null, '', ' ']) {
expect(() => projectsService.validateProjectName(bad)).toThrow(
expect.objectContaining({ code: 'missing_name' }),
);
}
});

it('treats a non-string as missing, and rejects out-of-bounds lengths as invalid', () => {
expect(() => projectsService.validateProjectName(42)).toThrow(
expect.objectContaining({ code: 'missing_name' }),
);
// 2-50 chars: a single char and 51 chars both fail.
expect(() => projectsService.validateProjectName('x')).toThrow(
expect.objectContaining({ code: 'invalid_name' }),
);
expect(() => projectsService.validateProjectName('x'.repeat(51))).toThrow(
expect.objectContaining({ code: 'invalid_name' }),
);
});
});

describe('validatePalettePayload', () => {
it('normalizes valid colors (ids kept, names trimmed, hex preserved)', () => {
const validated = projectsService.validatePalettePayload([
{ id: 7, name: ' Ink ', hex: '#112233' },
{ name: '', hex: '#abc' },
]);

expect(validated).toEqual([
{ id: 7, name: 'Ink', hex: '#112233' },
{ id: null, name: null, hex: '#abc' },
]);
});

it('rejects a non-array payload', () => {
expect(() => projectsService.validatePalettePayload({ hex: '#112233' })).toThrow(
/must be an array/,
);
});

it('rejects a palette over the 50-color cap', () => {
const tooMany = Array.from({ length: 51 }, (_, i) => ({
hex: `#${String(100000 + i).slice(0, 6)}`,
}));
expect(() => projectsService.validatePalettePayload(tooMany)).toThrow(/cannot exceed 50/);
});

it('rejects alpha-channel and malformed hex values', () => {
for (const hex of ['#11223344', '112233', '#11223g', 'red', null]) {
expect(() => projectsService.validatePalettePayload([{ hex }])).toThrow(/Invalid color/);
}
});

it('rejects forged color ids (non-integer or non-positive)', () => {
for (const id of ['abc', -1, 0, 1.5]) {
expect(() => projectsService.validatePalettePayload([{ id, hex: '#112233' }])).toThrow(
/Invalid color identifier/,
);
}
});

it('rejects an over-long color usage', () => {
expect(() =>
projectsService.validatePalettePayload([{ hex: '#112233', name: 'x'.repeat(256) }]),
).toThrow(/color usage is invalid/);
});
});

describe('addBrushNormToProject validation branches', () => {
const db = require('../../src/database');

beforeEach(() => {
jest.resetAllMocks();
});

const expectRejected = async (payload, pattern) => {
await expect(projectsService.addBrushNormToProject(1, payload)).rejects.toThrow(pattern);
expect(db.query).not.toHaveBeenCalled();
};

it('rejects a missing or blank usage name', async () => {
await expectRejected({ value: '8' }, /brush usage/);
await expectRejected({ name: ' ', value: '8' }, /brush usage/);
});

it('rejects a non-positive, non-numeric or oversized size', async () => {
await expectRejected({ name: 'Line', value: '0' }, /positive number/);
await expectRejected({ name: 'Line', value: 'huge' }, /positive number/);
await expectRejected({ name: 'Line', value: '1001' }, /positive number/);
await expectRejected({ name: 'Line', value: {} }, /positive number/);
});

it('rejects a unit with digits or over 20 chars', async () => {
await expectRejected({ name: 'Line', value: '8', unit: 'px2' }, /unit is invalid/);
await expectRejected({ name: 'Line', value: '8', unit: 'a'.repeat(21) }, /unit is invalid/);
});

it('rejects an out-of-range or non-numeric opacity', async () => {
await expectRejected({ name: 'Line', value: '8', opacity: '1.5' }, /between 0 and 1/);
await expectRejected({ name: 'Line', value: '8', opacity: [] }, /between 0 and 1/);
});

it('accepts the minimal valid payload, defaulting the unit to px', async () => {
db.query
.mockResolvedValueOnce([{ insertId: 9 }]) // INSERT
.mockResolvedValueOnce([{}]); // last_edited touch

const result = await projectsService.addBrushNormToProject(1, { name: 'Line', value: '8' });

expect(result).toEqual({ success: true, id: 9 });
expect(db.query.mock.calls[0][1]).toEqual(expect.arrayContaining(['px']));
});
});
Loading
Loading