Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/contact-logs/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ import {
getContactLogById,
} from './actions';

const validUserGuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const mockAuthSession = {
user: { id: 'internal-id', userGuid: 'user-guid-123' },
user: { id: 'internal-id', userGuid: validUserGuid },
};

describe('contact-logs actions', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/components/contact-logs/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ContactLog } from "@/lib/providers/ministry-platform/models/ContactLog"
import { ContactLogTypes } from "@/lib/providers/ministry-platform/models/ContactLogTypes";
import { ContactLogInput } from "@/lib/providers/ministry-platform/models/ContactLogSchema";
import { ContactLogService } from "@/services/contactLogService";
import { sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

Expand Down Expand Up @@ -50,7 +51,7 @@ export async function createContactLog(

const users = await mp.getTableRecords<{ User_ID: number }>({
table: "dp_Users",
filter: `User_GUID = '${userGuid}'`,
filter: `User_GUID = '${sanitizeGuid(userGuid)}'`,
select: "User_ID",
top: 1
});
Expand Down Expand Up @@ -102,7 +103,7 @@ export async function updateContactLog(

const users = await mp.getTableRecords<{ User_ID: number }>({
table: "dp_Users",
filter: `User_GUID = '${userGuid}'`,
filter: `User_GUID = '${sanitizeGuid(userGuid)}'`,
select: "User_ID",
top: 1
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilterValue, sanitizeGuid } from './filter-sanitize';
import { sanitizeFilterValue, sanitizeLikeValue, sanitizeGuid } from './filter-sanitize';

describe('sanitizeFilterValue', () => {
it('should return plain strings unchanged', () => {
Expand All @@ -23,6 +23,36 @@ describe('sanitizeFilterValue', () => {
});
});

describe('sanitizeLikeValue', () => {
it('should return plain strings unchanged', () => {
expect(sanitizeLikeValue('John')).toBe('John');
});

it('should escape single quotes', () => {
expect(sanitizeLikeValue("O'Brien")).toBe("O''Brien");
});

it('should escape percent wildcards', () => {
expect(sanitizeLikeValue('100%')).toBe('100\\%');
});

it('should escape underscore wildcards', () => {
expect(sanitizeLikeValue('a_b')).toBe('a\\_b');
});

it('should escape backslashes before other escapes', () => {
expect(sanitizeLikeValue('a\\b')).toBe('a\\\\b');
});

it('should escape all special characters together', () => {
expect(sanitizeLikeValue("100%_o'reilly\\")).toBe("100\\%\\_o''reilly\\\\");
});

it('should handle empty string', () => {
expect(sanitizeLikeValue('')).toBe('');
});
});

describe('sanitizeGuid', () => {
it('should return a valid lowercase GUID unchanged', () => {
const guid = '12345678-1234-1234-1234-123456789abc';
Expand Down
21 changes: 20 additions & 1 deletion src/lib/providers/ministry-platform/utils/filter-sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,33 @@
* Escapes a string value for safe interpolation inside a single-quoted filter value.
* Doubles single quotes (SQL standard escaping) so that input like O'Brien
* becomes O''Brien and cannot break out of the quoted context.
*
* Use for equality comparisons: `Column = '${sanitizeFilterValue(value)}'`.
* For LIKE patterns, use {@link sanitizeLikeValue} instead.
*/
export function sanitizeFilterValue(value: string): string {
return value.replace(/'/g, "''");
}

/**
* Escapes a string value for safe interpolation inside a LIKE pattern.
* Escapes the SQL LIKE wildcards (`%`, `_`) and the backslash escape character
* itself, then doubles single quotes for string-literal escaping. Callers MUST
* include `ESCAPE '\'` in the LIKE clause for the escapes to be honored, e.g.
* `Column LIKE '%${sanitizeLikeValue(value)}%' ESCAPE '\\'`.
*/
export function sanitizeLikeValue(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_")
.replace(/'/g, "''");
}

/**
* Validates a GUID/UUID string format and returns the sanitized value.
* Throws if the value does not match the expected UUID v4 pattern.
* Accepts any UUID variant (v1–v5) — Ministry Platform GUIDs are not guaranteed
* to be v4. Throws if the value does not match the canonical 8-4-4-4-12 hex format.
*/
export function sanitizeGuid(guid: string): string {
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Expand Down
3 changes: 2 additions & 1 deletion src/services/contactService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe('ContactService', () => {

describe('getContactByGuid', () => {
const validGuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const validButUnknownGuid = 'b2c3d4e5-f678-9012-3456-7890abcdef12';

it('should return contact when found', async () => {
const mockContact = { Contact_ID: 1, Contact_GUID: validGuid, First_Name: 'John' };
Expand All @@ -105,7 +106,7 @@ describe('ContactService', () => {
mockGetTableRecords.mockResolvedValueOnce([]);

const service = await ContactService.getInstance();
const result = await service.getContactByGuid(validGuid);
const result = await service.getContactByGuid(validButUnknownGuid);

expect(result).toBeNull();
});
Expand Down
8 changes: 6 additions & 2 deletions src/services/contactService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ContactSearch } from "@/lib/dto";
import { MPHelper } from "@/lib/providers/ministry-platform";
import { sanitizeFilterValue, sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize";
import { sanitizeLikeValue, sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize";

/**
* ContactService - Singleton service for managing contact-related operations
Expand Down Expand Up @@ -53,9 +53,13 @@ export class ContactService {
* @returns Promise<ContactSearch[]> - Array of matching contacts (limited to 20 results)
*/
public async contactSearch(search: string): Promise<ContactSearch[]> {
const term = sanitizeLikeValue(search);
const filter = ["First_Name", "Last_Name", "Nickname", "Email_Address", "Mobile_Phone"]
.map((col) => `${col} LIKE '%${term}%' ESCAPE '\\'`)
.join(" OR ");
const records = await this.mp!.getTableRecords<ContactSearch>({
table: "Contacts",
filter: `First_Name LIKE '%${sanitizeFilterValue(search)}%' OR Last_Name LIKE '%${sanitizeFilterValue(search)}%' OR Nickname LIKE '%${sanitizeFilterValue(search)}%' OR Email_Address LIKE '%${sanitizeFilterValue(search)}%' OR Mobile_Phone LIKE '%${sanitizeFilterValue(search)}%'`,
filter,
select: "Contact_ID, Contact_GUID,First_Name,Nickname,Last_Name,Email_Address,Mobile_Phone,dp_fileUniqueId AS Image_GUID",
top: 20
});
Expand Down
22 changes: 15 additions & 7 deletions src/services/userService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ describe('UserService', () => {
});

describe('getUserProfile', () => {
const validGuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const otherValidGuid = 'b2c3d4e5-f678-9012-3456-7890abcdef12';

it('should fetch user profile with roles and groups', async () => {
const mockProfile = {
User_ID: 1,
User_GUID: 'test-guid-123',
User_GUID: validGuid,
Contact_ID: 100,
First_Name: 'John',
Nickname: 'Johnny',
Expand All @@ -46,12 +49,12 @@ describe('UserService', () => {
.mockResolvedValueOnce([{ User_Group_Name: 'Staff' }]);

const service = await UserService.getInstance();
const result = await service.getUserProfile('test-guid-123');
const result = await service.getUserProfile(validGuid);

expect(mockGetTableRecords).toHaveBeenCalledTimes(3);
expect(mockGetTableRecords).toHaveBeenCalledWith({
table: 'dp_Users',
filter: "User_GUID = 'test-guid-123'",
filter: `User_GUID = '${validGuid}'`,
select: expect.stringContaining('User_ID'),
top: 1,
});
Expand All @@ -76,7 +79,7 @@ describe('UserService', () => {
mockGetTableRecords.mockResolvedValueOnce([]);

const service = await UserService.getInstance();
const result = await service.getUserProfile('nonexistent-guid');
const result = await service.getUserProfile(validGuid);

expect(result).toBeUndefined();
expect(mockGetTableRecords).toHaveBeenCalledTimes(1);
Expand All @@ -85,7 +88,7 @@ describe('UserService', () => {
it('should return empty arrays when user has no roles or groups', async () => {
const mockProfile = {
User_ID: 2,
User_GUID: 'test-guid-456',
User_GUID: otherValidGuid,
Contact_ID: 200,
First_Name: 'Jane',
Nickname: 'Jane',
Expand All @@ -100,7 +103,7 @@ describe('UserService', () => {
.mockResolvedValueOnce([]);

const service = await UserService.getInstance();
const result = await service.getUserProfile('test-guid-456');
const result = await service.getUserProfile(otherValidGuid);

expect(result).toEqual({
...mockProfile,
Expand All @@ -113,7 +116,12 @@ describe('UserService', () => {
mockGetTableRecords.mockRejectedValueOnce(new Error('API error'));

const service = await UserService.getInstance();
await expect(service.getUserProfile('test-guid')).rejects.toThrow('API error');
await expect(service.getUserProfile(validGuid)).rejects.toThrow('API error');
});

it('should throw on invalid GUID format', async () => {
const service = await UserService.getInstance();
await expect(service.getUserProfile('not-a-guid')).rejects.toThrow('Invalid GUID format');
});
});
});
3 changes: 2 additions & 1 deletion src/services/userService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MPUserProfile } from "@/lib/providers/ministry-platform/types";
import { MPHelper } from "@/lib/providers/ministry-platform";
import { sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize";

/**
* UserService - Singleton service for managing user-related operations
Expand Down Expand Up @@ -60,7 +61,7 @@ export class UserService {
public async getUserProfile(id: string): Promise<MPUserProfile | undefined> {
const records = await this.mp!.getTableRecords<MPUserProfile>({
table: "dp_Users",
filter: `User_GUID = '${id}'`,
filter: `User_GUID = '${sanitizeGuid(id)}'`,
select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID",
top: 1
});
Expand Down
Loading