From b51ea2505e0c81b569a0a18350d1b786aa6d8464 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 12 Jun 2026 23:56:56 +0200 Subject: [PATCH] feat(db): stock CHECK constraints + audit_logs DATETIME (2038 fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 20260612234500: CHECK (quantity >= 0) on sklad_batches and sklad_item_locations — the DB itself now rejects negative stock, so a future C2-class bug (cumulative decrements driving a batch negative and silently vanishing from totals) becomes a loud error instead of hidden corruption. All three databases verified clean of negative rows before the migration. audit_logs.created_at TIMESTAMP(0) -> DATETIME(0): TIMESTAMP dies in 2038 and converts through the session timezone; existing wall times preserved by the MODIFY. Pinned by db-constraints.test.ts (4 tests, each watched fail pre-migration: negative insert/update/location all succeeded before, 2098 audit timestamp was rejected before). Co-Authored-By: Claude Fable 5 --- package-lock.json | 4 +- package.json | 2 +- .../migration.sql | 14 ++ prisma/schema.prisma | 4 +- src/__tests__/db-constraints.test.ts | 136 ++++++++++++++++++ 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260612234500_stock_checks_and_audit_datetime/migration.sql create mode 100644 src/__tests__/db-constraints.test.ts diff --git a/package-lock.json b/package-lock.json index a4470d1..dfe6c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "2.4.32", + "version": "2.4.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "2.4.32", + "version": "2.4.33", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.102.0", diff --git a/package.json b/package.json index ea2f5d2..226e881 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "2.4.32", + "version": "2.4.33", "description": "", "main": "dist/server.js", "scripts": { diff --git a/prisma/migrations/20260612234500_stock_checks_and_audit_datetime/migration.sql b/prisma/migrations/20260612234500_stock_checks_and_audit_datetime/migration.sql new file mode 100644 index 0000000..e9712c2 --- /dev/null +++ b/prisma/migrations/20260612234500_stock_checks_and_audit_datetime/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0); + + +-- Stock invariants enforced by the database itself: a future cumulative- +-- decrement bug (the C2 class — duplicate issue lines driving a batch +-- negative and silently vanishing from stock totals) becomes a loud error +-- instead of hidden corruption. All databases verified clean of negative +-- rows before this migration (2026-06-12). +ALTER TABLE `sklad_batches` + ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0); + +ALTER TABLE `sklad_item_locations` + ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1142cc9..e12e01b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,7 +65,9 @@ model audit_logs { new_values String? @db.LongText user_agent String? @db.Text session_id String? @db.VarChar(128) - created_at DateTime? @default(now()) @db.Timestamp(0) + // DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through + // the session timezone (2026-06-12 hardening migration). + created_at DateTime? @default(now()) @db.DateTime(0) @@index([action], map: "idx_audit_logs_action") @@index([created_at], map: "idx_audit_logs_created") diff --git a/src/__tests__/db-constraints.test.ts b/src/__tests__/db-constraints.test.ts new file mode 100644 index 0000000..59e3611 --- /dev/null +++ b/src/__tests__/db-constraints.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; + +/** + * Pins the 2026-06-12 DB hardening migration: + * 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB + * itself rejects negative stock, so a future C2-class bug (cumulative + * decrements driving a batch negative) becomes a loud error instead of + * silently hidden corruption. + * 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap. + */ + +const N = "db_constraints_"; + +let itemId: number; +let locationId: number; +let receiptId: number; +let lineAId: number; +let lineBId: number; + +async function cleanup() { + await prisma.sklad_batches.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_item_locations.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipt_lines.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } }); + await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); + await prisma.sklad_locations.deleteMany({ + where: { name: { contains: N } }, + }); + await prisma.audit_logs.deleteMany({ + where: { description: { contains: N } }, + }); +} + +beforeAll(async () => { + await cleanup(); + const item = await prisma.sklad_items.create({ + data: { name: `${N}item`, unit: "ks" }, + }); + itemId = item.id; + const location = await prisma.sklad_locations.create({ + data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` }, + }); + locationId = location.id; + const receipt = await prisma.sklad_receipts.create({ + data: { notes: `${N}receipt`, status: "CONFIRMED" }, + }); + receiptId = receipt.id; + const lineA = await prisma.sklad_receipt_lines.create({ + data: { + receipt_id: receiptId, + item_id: itemId, + quantity: 5, + unit_price: 1, + }, + }); + lineAId = lineA.id; + const lineB = await prisma.sklad_receipt_lines.create({ + data: { + receipt_id: receiptId, + item_id: itemId, + quantity: 5, + unit_price: 1, + }, + }); + lineBId = lineB.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("stock CHECK constraints", () => { + it("rejects inserting a negative batch quantity", async () => { + await expect( + prisma.sklad_batches.create({ + data: { + item_id: itemId, + receipt_line_id: lineAId, + quantity: -1, + original_qty: 5, + unit_price: 1, + is_consumed: false, + }, + }), + ).rejects.toThrow(); + }); + + it("rejects updating a batch quantity below zero", async () => { + const batch = await prisma.sklad_batches.create({ + data: { + item_id: itemId, + receipt_line_id: lineBId, + quantity: 5, + original_qty: 5, + unit_price: 1, + is_consumed: false, + }, + }); + await expect( + prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`, + ).rejects.toThrow(); + const fresh = await prisma.sklad_batches.findUnique({ + where: { id: batch.id }, + }); + expect(Number(fresh?.quantity)).toBe(5); + }); + + it("rejects a negative item_location quantity", async () => { + await expect( + prisma.sklad_item_locations.create({ + data: { item_id: itemId, location_id: locationId, quantity: -1 }, + }), + ).rejects.toThrow(); + }); +}); + +describe("audit_logs.created_at has no 2038 cap", () => { + it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => { + const row = await prisma.audit_logs.create({ + data: { + action: "test", + description: `${N}future`, + created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)), + }, + }); + expect(row.created_at?.getUTCFullYear()).toBe(2098); + }); +});