fix(stock): require raw-material permission to consume, guard quantity edits
Security review findings on the raw material commit: - Creating a production batch with inputs books raw material off other lots; production-batch:create alone no longer suffices, the user also needs raw-material:withdraw (403 otherwise). - Correcting the quantity of a raw lot or production batch below what was already consumed, sold or withdrawn is refused (row lock + check in the same transaction) instead of silently driving the remaining stock negative; shortfalls go through an inventory correction. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
e52a6057a2
commit
f9899d5621
4 files changed
+132
-24
No files matched your search
@@ -176,6 +176,61 @@ describe('Raw materials and production (e2e)', () => {
|
||||
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses to correct a raw lot below what was already used or withdrawn', async () => {
|
||||
// wax: 1000 g harvested, 100 g withdrawn -> at least 100 g must remain booked
|
||||
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
|
||||
.send({ quantity: 50 })
|
||||
.expect(400);
|
||||
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
|
||||
.send({ quantity: 950 })
|
||||
.expect(200);
|
||||
expect((await stockOf(waxId)).summary.remaining).toBe(850);
|
||||
});
|
||||
|
||||
it('needs the raw material withdraw permission to consume raw material', async () => {
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `PRODUCER_ONLY_${suffix}`,
|
||||
name: `Nur Herstellung ${suffix}`,
|
||||
permissions: {
|
||||
create: (
|
||||
await prisma.permission.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: ['production-batch:read', 'production-batch:create'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).map((permission) => ({ permissionId: permission.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `raw-producer-${suffix}@example.com`,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Producer',
|
||||
roleId: role.id,
|
||||
},
|
||||
});
|
||||
const producerToken = (
|
||||
await login(app, `raw-producer-${suffix}@example.com`, password)
|
||||
).accessToken;
|
||||
|
||||
await request(server())
|
||||
.post('/api/v1/production-batches')
|
||||
.set('Authorization', `Bearer ${producerToken}`)
|
||||
.send({
|
||||
productId,
|
||||
lotNumber: `S3-${suffix}`,
|
||||
date: '2026-06-12',
|
||||
quantity: 5,
|
||||
inputs: [{ rawMaterialLotId: propolisId, quantity: 10 }],
|
||||
})
|
||||
.expect(403);
|
||||
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a raw lot with movements as history', async () => {
|
||||
await auth(
|
||||
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
|
||||
|
||||
Reference in new issue
Block a user