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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:31:06 +02:00
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}`),