fix: block creating or moving a colony into an inactive/missing apiary

Colony.apiaryId was never checked against the target apiary at all -
create() would hit a raw Prisma FK-constraint error for a nonexistent
apiary instead of a clean 404, and both create() and move() would
silently succeed against a deactivated apiary, orphaning the colony
the same way (just from the opposite direction) as the apiary-side gap
fixed in the previous commit.

Adds a shared assertApiaryActive() check, following the same
findUnique-then-status pattern already used for cross-entity checks
elsewhere (e.g. QueensService.assertMotherQueenExists), used by both
create() and move().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-08 22:32:53 +02:00
1 parent 961a237335
commit 5c5c7b756f
2 files changed
+66

No files matched your search

@@ -20,6 +20,7 @@ describe('ColoniesService', () => {
create: jest.Mock;
update: jest.Mock;
};
apiary: { findUnique: jest.Mock };
colonyApiaryHistory: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
colonyLineage: { findMany: jest.Mock };
@@ -49,6 +50,11 @@ describe('ColoniesService', () => {
create: jest.fn(),
update: jest.fn(),
},
apiary: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'apiary-2', status: 'ACTIVE' }),
},
colonyApiaryHistory: { findMany: jest.fn() },
queenColonyHistory: { findMany: jest.fn() },
colonyLineage: { findMany: jest.fn() },
@@ -185,6 +191,45 @@ describe('ColoniesService', () => {
).rejects.toBeInstanceOf(ConflictException);
});
it('move rejects a nonexistent destination apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(
service.move('colony-1', { apiaryId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('move rejects an inactive destination apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.apiary.findUnique.mockResolvedValue({
id: 'apiary-2',
status: 'INACTIVE',
});
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('create rejects an inactive apiary', async () => {
prisma.apiary.findUnique.mockResolvedValue({
id: 'apiary-1',
status: 'INACTIVE',
});
await expect(
service.create(
{
code: 'V-002',
apiaryId: 'apiary-1',
colonyType: 'PRODUCTION_COLONY',
},
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.create).not.toHaveBeenCalled();
});
it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
@@ -51,7 +51,27 @@ export class ColoniesService {
}
}
// Colony.apiaryId is a plain FK with no built-in status check - without
// this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would).
private async assertApiaryActive(apiaryId: string): Promise<void> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
});
if (!apiary) {
throw new NotFoundException(`Apiary ${apiaryId} not found`);
}
if (apiary.status !== 'ACTIVE') {
throw new ConflictException(
`Apiary ${apiaryId} is not active (status: ${apiary.status})`,
);
}
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Colony',
@@ -93,6 +113,7 @@ export class ColoniesService {
): Promise<Colony> {
const before = await this.findById(id);
this.assertActive(before);
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id },