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
20 changes: 18 additions & 2 deletions app/schemas/billingtype.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,33 @@ const intIdParam = z.object({
id: z.coerce.number().int().positive(),
});

// `btHourlyRate` is a Sequelize DOUBLE column for the hourly rate
// charged against a BillingType. `.nonnegative()` blocks negatives
// (a -$50/hr rate is operator error) but DOES NOT block `Infinity`
// — `Infinity >= 0` is true. The coerce path also turns the string
// `"Infinity"` into the float, so JSON without an Infinity literal
// can still land `inf` in the column and contaminate downstream
// invoice/time-entry totals.
//
// Chain `.finite()` ahead of `.nonnegative()` to reject the
// infinities. Zero remains a valid rate (pro-bono / internal-only
// billing entries). Mirrors the cpayAmount / injbAmount / polPrice
// validators (#172 / #180 / #194).
const btHourlyRateField = z.coerce.number()
.finite({ message: 'btHourlyRate must be a finite number.' })
.nonnegative();

const createBillingTypeBody = z.object({
btName: z.string().min(1).max(255),
btHourlyRate: z.coerce.number().nonnegative(),
btHourlyRate: btHourlyRateField,
btCompId: z.coerce.number().int().positive().optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: btName, btHourlyRate, btCompId.',
});

const updateBillingTypeBody = z.object({
btName: z.string().min(1).max(255).optional(),
btHourlyRate: z.coerce.number().nonnegative().optional(),
btHourlyRate: btHourlyRateField.optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: btName, btHourlyRate.',
});
Expand Down
38 changes: 38 additions & 0 deletions tests/api/billingtype.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,42 @@ describe('BillingType body validation', () => {
.send({ btName: 'Standard' });
expect(res.status).toBe(400);
});

test('POST rejects non-finite btHourlyRate (string "Infinity" coerces past nonnegative())', async () => {
// .nonnegative() allows Infinity (Infinity >= 0 is true).
// .finite() in the validator catches it before .nonnegative().
const res = await request(app)
.post('/v1/billingtype')
.set('authKey', 'any')
.send({ btName: 'Standard', btHourlyRate: 'Infinity' });
expect(res.status).toBe(400);
});

test('POST still rejects negative btHourlyRate', async () => {
// Pin the existing .nonnegative() guard so .finite() doesn't
// accidentally relax the negative-block when refactoring.
const res = await request(app)
.post('/v1/billingtype')
.set('authKey', 'any')
.send({ btName: 'Standard', btHourlyRate: -50 });
expect(res.status).toBe(400);
});

test('POST accepts zero btHourlyRate (pro-bono / internal billing)', async () => {
// Zero is a legitimate rate (pro-bono engagements, internal-only
// entries). .finite() + .nonnegative() should let it through.
const res = await request(app)
.post('/v1/billingtype')
.set('authKey', 'any')
.send({ btName: 'Pro-bono', btHourlyRate: 0 });
expect(res.status).not.toBe(400);
});

test('PATCH rejects non-finite btHourlyRate', async () => {
const res = await request(app)
.patch('/v1/billingtype/1')
.set('authKey', 'any')
.send({ btHourlyRate: '-Infinity' });
expect(res.status).toBe(400);
});
});