Files
pn-new-crm/tests/unit/custom-field-validation.test.ts

241 lines
8.7 KiB
TypeScript
Raw Permalink Normal View History

/**
* Tests for validateCustomFieldValue the private validation helper in
* custom-fields.service.ts. Since it is not exported we test it via the
* public setValues function, using vi.mock to avoid database calls.
* All assertions focus on what error message (if any) is thrown.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ─── Mock database + dependencies ────────────────────────────────────────────
vi.mock('@/lib/db', () => ({
db: {
query: {
customFieldDefinitions: { findMany: vi.fn(), findFirst: vi.fn() },
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
// Entity-port-scope checks added by the security fix; default to a
// truthy row so existing assertions still focus on validation logic.
clients: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
interests: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
berths: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
yachts: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
companies: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
},
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
select: vi.fn(),
},
}));
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
vi.mock('@/lib/db/schema/clients', () => ({ clients: {} }));
vi.mock('@/lib/db/schema/interests', () => ({ interests: {} }));
vi.mock('@/lib/db/schema/berths', () => ({ berths: {} }));
vi.mock('@/lib/db/schema/yachts', () => ({ yachts: {} }));
vi.mock('@/lib/db/schema/companies', () => ({ companies: {} }));
vi.mock('@/lib/audit', () => ({
createAuditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn() },
}));
vi.mock('@/lib/db/schema/system', () => ({
customFieldDefinitions: {},
customFieldValues: {},
}));
// next/server is not available in vitest node environment
vi.mock('next/server', () => ({
NextResponse: {
json: vi.fn(),
},
}));
import { setValues } from '@/lib/services/custom-fields.service';
import { db } from '@/lib/db';
import { ValidationError } from '@/lib/errors';
// ─── Helper to build a minimal CustomFieldDefinition ─────────────────────────
function makeDefinition(
fieldType: string,
extras: { isRequired?: boolean; selectOptions?: string[] } = {},
) {
return {
id: 'field-1',
portId: 'port-1',
entityType: 'client',
fieldName: 'test_field',
fieldLabel: 'Test Field',
fieldType,
selectOptions: extras.selectOptions ?? null,
isRequired: extras.isRequired ?? false,
sortOrder: 0,
createdAt: new Date(),
};
}
const AUDIT_META = {
userId: 'user-1',
portId: 'port-1',
ipAddress: '127.0.0.1',
userAgent: 'test',
};
beforeEach(() => {
vi.clearAllMocks();
// Default: no existing values, upsert succeeds
const insertChain = {
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ id: 'cfv-1' }]),
};
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue(insertChain);
});
/** Convenience: call setValues with a single field/value pair. */
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
async function validate(
fieldType: string,
value: unknown,
extras?: { isRequired?: boolean; selectOptions?: string[] },
) {
(db.query.customFieldDefinitions.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
makeDefinition(fieldType, extras),
]);
return setValues('entity-1', 'port-1', 'user-1', [{ fieldId: 'field-1', value }], AUDIT_META);
}
// ─── text ─────────────────────────────────────────────────────────────────────
describe('custom field validation — text', () => {
it('accepts a string value', async () => {
await expect(validate('text', 'hello')).resolves.toBeDefined();
});
it('rejects a number value', async () => {
await expect(validate('text', 42)).rejects.toBeInstanceOf(ValidationError);
});
it('rejects a boolean value', async () => {
await expect(validate('text', true)).rejects.toBeInstanceOf(ValidationError);
});
it('rejects a string longer than 1000 chars', async () => {
await expect(validate('text', 'x'.repeat(1001))).rejects.toBeInstanceOf(ValidationError);
});
});
// ─── number ──────────────────────────────────────────────────────────────────
describe('custom field validation — number', () => {
it('accepts a valid number', async () => {
await expect(validate('number', 42)).resolves.toBeDefined();
});
it('accepts zero', async () => {
await expect(validate('number', 0)).resolves.toBeDefined();
});
it('rejects a string', async () => {
await expect(validate('number', '42')).rejects.toBeInstanceOf(ValidationError);
});
it('rejects NaN', async () => {
await expect(validate('number', NaN)).rejects.toBeInstanceOf(ValidationError);
});
});
// ─── date ─────────────────────────────────────────────────────────────────────
describe('custom field validation — date', () => {
it('accepts a valid ISO date string', async () => {
await expect(validate('date', '2026-06-15')).resolves.toBeDefined();
});
it('accepts a full ISO datetime string', async () => {
await expect(validate('date', '2026-06-15T10:00:00.000Z')).resolves.toBeDefined();
});
it('rejects "not-a-date"', async () => {
await expect(validate('date', 'not-a-date')).rejects.toBeInstanceOf(ValidationError);
});
it('rejects a number', async () => {
await expect(validate('date', 20260615)).rejects.toBeInstanceOf(ValidationError);
});
});
// ─── boolean ─────────────────────────────────────────────────────────────────
describe('custom field validation — boolean', () => {
it('accepts true', async () => {
await expect(validate('boolean', true)).resolves.toBeDefined();
});
it('accepts false', async () => {
await expect(validate('boolean', false)).resolves.toBeDefined();
});
it('rejects the string "true"', async () => {
await expect(validate('boolean', 'true')).rejects.toBeInstanceOf(ValidationError);
});
it('rejects 1 (number)', async () => {
await expect(validate('boolean', 1)).rejects.toBeInstanceOf(ValidationError);
});
});
// ─── select ──────────────────────────────────────────────────────────────────
describe('custom field validation — select', () => {
const options = ['Small', 'Medium', 'Large'];
it('accepts a valid option', async () => {
await expect(validate('select', 'Small', { selectOptions: options })).resolves.toBeDefined();
});
it('rejects an option not in the list', async () => {
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
await expect(validate('select', 'XL', { selectOptions: options })).rejects.toBeInstanceOf(
ValidationError,
);
});
it('error message lists the valid options', async () => {
try {
await validate('select', 'XL', { selectOptions: options });
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(ValidationError);
// The service wraps the error in ValidationError with an errors array
const ve = err as ValidationError;
const messages = JSON.stringify(ve);
expect(messages).toMatch(/Small|Medium|Large/);
}
});
});
// ─── required / non-required null handling ───────────────────────────────────
describe('custom field validation — required vs optional null', () => {
it('required field: null value → throws ValidationError', async () => {
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
await expect(validate('text', null, { isRequired: true })).rejects.toBeInstanceOf(
ValidationError,
);
});
it('required field: undefined value → throws ValidationError', async () => {
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
await expect(validate('text', undefined, { isRequired: true })).rejects.toBeInstanceOf(
ValidationError,
);
});
it('non-required field: null value → succeeds (no error)', async () => {
// null for non-required means "clear the value" — setValues will upsert null
await expect(validate('text', null, { isRequired: false })).resolves.toBeDefined();
});
});