Security-driven version bumps; both stay within their existing major.
next 15.2.9 → 15.5.18 closes (1 high + 6 moderate next-specific CVEs):
- DoS via Server Components (high)
- Image Optimizer cache key confusion / content injection (moderate)
- Improper middleware redirect handling → SSRF (moderate)
- HTTP request smuggling in rewrites (moderate)
- Unbounded next/image disk cache growth → storage exhaustion (moderate)
- Self-hosted DoS via Image Optimizer remotePatterns (moderate)
drizzle-orm 0.38.4 → 0.45.2 closes:
- SQL injection via improperly escaped SQL identifiers (high)
Drizzle 0.45 changed query-error wrapping: outer Error.message is now
generic ("Failed query: insert into ...") with the postgres error on
.cause. Two integration test suites updated to assert on
cause.code === '23505' (postgres unique_violation) instead of message
regex — more robust + unambiguous.
eslint-config-next bumped 15.2.9 → 15.5.18 to match.
drizzle-kit bumped 0.30.6 → 0.31.10 to match.
Note: next-env.d.ts is auto-generated by next at build time; not
committed here (the new triple-slash routes reference would fail the
project's eslint rule, and CI regenerates it anyway).
Tests: 1185/1185 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
/**
|
|
* Schema constraint integration tests.
|
|
*
|
|
* Verifies DB-level enforcement of:
|
|
* - Partial unique index idx_yoh_active (one active ownership row per yacht)
|
|
* - Partial unique index idx_br_active (one active reservation per berth)
|
|
* - Non-active reservations on the same berth are permitted
|
|
* - Case-insensitive company name uniqueness within a port
|
|
* - Same company name allowed across different ports
|
|
*/
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
|
import { companies } from '@/lib/db/schema/companies';
|
|
import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
|
|
|
|
// ─── DB availability ─────────────────────────────────────────────────────────
|
|
|
|
let dbAvailable = false;
|
|
|
|
beforeAll(async () => {
|
|
try {
|
|
await db.execute(`SELECT 1`);
|
|
dbAvailable = true;
|
|
} catch (err) {
|
|
console.warn(
|
|
'[schema-constraints] DATABASE_URL not reachable — skipping integration tests',
|
|
err,
|
|
);
|
|
}
|
|
});
|
|
|
|
function itDb(name: string, fn: () => Promise<void>) {
|
|
it(name, async () => {
|
|
if (!dbAvailable) return;
|
|
await fn();
|
|
});
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('schema constraints', () => {
|
|
itDb(
|
|
'rejects a second active ownership row per yacht (partial unique idx_yoh_active)',
|
|
async () => {
|
|
const port = await makePort();
|
|
const clientA = await makeClient({ portId: port.id });
|
|
const clientB = await makeClient({ portId: port.id });
|
|
const yacht = await makeYacht({
|
|
portId: port.id,
|
|
ownerType: 'client',
|
|
ownerId: clientA.id,
|
|
});
|
|
// makeYacht already inserted one active (end_date IS NULL) ownership row.
|
|
|
|
await expect(
|
|
db.insert(yachtOwnershipHistory).values({
|
|
yachtId: yacht.id,
|
|
ownerType: 'client',
|
|
ownerId: clientB.id,
|
|
startDate: new Date(),
|
|
endDate: null, // another open row — should violate partial unique
|
|
createdBy: 'test',
|
|
}),
|
|
).rejects.toMatchObject({
|
|
cause: expect.objectContaining({ code: '23505' }),
|
|
});
|
|
},
|
|
);
|
|
|
|
itDb('rejects a second active reservation per berth (partial unique idx_br_active)', async () => {
|
|
const port = await makePort();
|
|
const clientA = await makeClient({ portId: port.id });
|
|
const clientB = await makeClient({ portId: port.id });
|
|
const yachtA = await makeYacht({
|
|
portId: port.id,
|
|
ownerType: 'client',
|
|
ownerId: clientA.id,
|
|
});
|
|
const yachtB = await makeYacht({
|
|
portId: port.id,
|
|
ownerType: 'client',
|
|
ownerId: clientB.id,
|
|
});
|
|
const berth = await makeBerth({ portId: port.id });
|
|
|
|
await db.insert(berthReservations).values({
|
|
berthId: berth.id,
|
|
portId: port.id,
|
|
clientId: clientA.id,
|
|
yachtId: yachtA.id,
|
|
status: 'active',
|
|
startDate: new Date(),
|
|
createdBy: 'test',
|
|
});
|
|
|
|
await expect(
|
|
db.insert(berthReservations).values({
|
|
berthId: berth.id,
|
|
portId: port.id,
|
|
clientId: clientB.id,
|
|
yachtId: yachtB.id,
|
|
status: 'active',
|
|
startDate: new Date(),
|
|
createdBy: 'test',
|
|
}),
|
|
).rejects.toMatchObject({
|
|
cause: expect.objectContaining({ code: '23505' }),
|
|
});
|
|
});
|
|
|
|
itDb(
|
|
'allows multiple non-active reservations on the same berth (partial index ignores non-active)',
|
|
async () => {
|
|
const port = await makePort();
|
|
const clientA = await makeClient({ portId: port.id });
|
|
const yachtA = await makeYacht({
|
|
portId: port.id,
|
|
ownerType: 'client',
|
|
ownerId: clientA.id,
|
|
});
|
|
const berth = await makeBerth({ portId: port.id });
|
|
|
|
// Two ended reservations on same berth — both should succeed
|
|
// (partial index only constrains status='active').
|
|
await expect(
|
|
db.insert(berthReservations).values([
|
|
{
|
|
berthId: berth.id,
|
|
portId: port.id,
|
|
clientId: clientA.id,
|
|
yachtId: yachtA.id,
|
|
status: 'ended',
|
|
startDate: new Date('2024-01-01'),
|
|
endDate: new Date('2024-06-30'),
|
|
createdBy: 'test',
|
|
},
|
|
{
|
|
berthId: berth.id,
|
|
portId: port.id,
|
|
clientId: clientA.id,
|
|
yachtId: yachtA.id,
|
|
status: 'ended',
|
|
startDate: new Date('2024-07-01'),
|
|
endDate: new Date('2024-12-31'),
|
|
createdBy: 'test',
|
|
},
|
|
]),
|
|
).resolves.toBeDefined();
|
|
},
|
|
);
|
|
|
|
itDb('enforces case-insensitive company name uniqueness per port', async () => {
|
|
const port = await makePort();
|
|
await makeCompany({ portId: port.id, overrides: { name: 'Aegean Holdings' } });
|
|
|
|
await expect(
|
|
db.insert(companies).values({ portId: port.id, name: 'AEGEAN HOLDINGS' }),
|
|
).rejects.toMatchObject({
|
|
cause: expect.objectContaining({ code: '23505' }),
|
|
});
|
|
});
|
|
|
|
itDb('allows same-name companies in different ports', async () => {
|
|
const portA = await makePort();
|
|
const portB = await makePort();
|
|
await makeCompany({ portId: portA.id, overrides: { name: 'Aegean Holdings' } });
|
|
|
|
await expect(
|
|
db.insert(companies).values({ portId: portB.id, name: 'Aegean Holdings' }),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|