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
117 changes: 117 additions & 0 deletions src/modules/creator/creator-display-name-sanitize.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
sanitizeDisplayName,
sanitizeAndValidateDisplayName,
validateDisplayName,
} from './creator-display-name-sanitize.utils';

describe('creator display name sanitiser and validator', () => {
describe('sanitizeDisplayName', () => {
it('passes plain text name through unchanged', () => {
expect(sanitizeDisplayName('Alice')).toBe('Alice');
expect(sanitizeDisplayName('Creator Name')).toBe('Creator Name');
});

it('strips HTML tags leaving only text content', () => {
expect(sanitizeDisplayName('<script>alert(1)</script>')).toBe(
'alert(1)'
);
expect(sanitizeDisplayName('<b>Jane</b> <i>Doe</i>')).toBe('Jane Doe');
expect(
sanitizeDisplayName('<div class="profile"><h1>Alice</h1></div>')
).toBe('Alice');
});

it('normalizes extra whitespace and trims result', () => {
expect(sanitizeDisplayName(' John Doe ')).toBe('John Doe');
expect(sanitizeDisplayName('<b> Alice </b>')).toBe('Alice');
});
});

describe('sanitizeAndValidateDisplayName', () => {
it('passes a plain text name through unchanged', () => {
const result = sanitizeAndValidateDisplayName('Creator Name');
expect(result).toEqual({
success: true,
data: 'Creator Name',
});
});

it('strips HTML tags and passes remaining text content', () => {
const result = sanitizeAndValidateDisplayName(
'<script>alert(1)</script>'
);
expect(result).toEqual({
success: true,
data: 'alert(1)',
});
});

it('passes a name of exactly 50 characters', () => {
const exactly50Chars = 'a'.repeat(50);
const result = sanitizeAndValidateDisplayName(exactly50Chars);
expect(result).toEqual({
success: true,
data: exactly50Chars,
});
expect((result as { success: true; data: string }).data.length).toBe(
50
);
});

it('fails a name of 51 characters with display_name_too_long', () => {
const fiftyOneChars = 'a'.repeat(51);
const result = sanitizeAndValidateDisplayName(fiftyOneChars);
expect(result).toEqual({
success: false,
error: 'display_name_too_long',
});
});

it('fails an empty string after stripping with display_name_empty', () => {
expect(sanitizeAndValidateDisplayName('')).toEqual({
success: false,
error: 'display_name_empty',
});
expect(sanitizeAndValidateDisplayName(' ')).toEqual({
success: false,
error: 'display_name_empty',
});
expect(sanitizeAndValidateDisplayName('<script></script>')).toEqual({
success: false,
error: 'display_name_empty',
});
expect(sanitizeAndValidateDisplayName('<b> </b>')).toEqual({
success: false,
error: 'display_name_empty',
});
});

it('strips HTML tags before evaluating 50-character limit', () => {
// Raw string is 57 characters, but after tag stripping it is 50 characters
const rawWithTags = '<b>' + 'x'.repeat(50) + '</b>';
const result = sanitizeAndValidateDisplayName(rawWithTags);
expect(result).toEqual({
success: true,
data: 'x'.repeat(50),
});
});
});

describe('validateDisplayName helper', () => {
it('returns sanitized string for valid input', () => {
expect(validateDisplayName('<b>Alice</b>')).toBe('Alice');
});

it('throws Error with display_name_too_long when exceeding 50 chars', () => {
expect(() => validateDisplayName('b'.repeat(51))).toThrow(
'display_name_too_long'
);
});

it('throws Error with display_name_empty when empty post-strip', () => {
expect(() => validateDisplayName('<script></script>')).toThrow(
'display_name_empty'
);
});
});
});
81 changes: 81 additions & 0 deletions src/modules/creator/creator-display-name-sanitize.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export const DISPLAY_NAME_MAX_LENGTH = 50;

export type DisplayNameValidationError =
'display_name_too_long' | 'display_name_empty';

export type DisplayNameSanitizeResult =
| { success: true; data: string }
| { success: false; error: DisplayNameValidationError };

/**
* Strips HTML tags from display name, removes invisible Unicode characters, and trims whitespace.
*
* @param displayName - The raw display name input string.
* @returns The sanitized display name with HTML tags stripped and whitespace normalized.
*/
export function sanitizeDisplayName(displayName: string): string {
if (typeof displayName !== 'string') {
return '';
}

let sanitized = displayName;

// Strip HTML tags (e.g. <script>alert(1)</script> -> alert(1))
sanitized = sanitized.replace(/<[^>]*>/g, '');

// Remove invisible Unicode characters (zero-width spaces, control characters, etc.)
sanitized = sanitized.replace(
/[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200D\u2060\uFEFF]/g,
''
);

// Normalize whitespace and trim
sanitized = sanitized.replace(/\s+/g, ' ').trim();

return sanitized;
}

/**
* Sanitizes and validates a creator display name against persistence requirements.
*
* Rules:
* 1. HTML tags are stripped and whitespace is normalized/trimmed.
* 2. If the resulting sanitized name is empty, fails with 'display_name_empty'.
* 3. If the resulting sanitized name exceeds 50 characters, fails with 'display_name_too_long'.
* 4. Otherwise, succeeds with the sanitized name string.
*
* @param displayName - The raw display name input string.
* @returns DisplayNameSanitizeResult object indicating success or specific validation error.
*/
export function sanitizeAndValidateDisplayName(
displayName: string
): DisplayNameSanitizeResult {
const sanitized = sanitizeDisplayName(displayName);

if (sanitized.length === 0) {
return { success: false, error: 'display_name_empty' };
}

if (sanitized.length > DISPLAY_NAME_MAX_LENGTH) {
return { success: false, error: 'display_name_too_long' };
}

return { success: true, data: sanitized };
}

/**
* Helper function that validates and sanitizes a display name, throwing an Error with code if invalid.
*
* @param displayName - The raw display name string.
* @returns The sanitized display name.
* @throws Error with message/code 'display_name_empty' or 'display_name_too_long' if invalid.
*/
export function validateDisplayName(displayName: string): string {
const result = sanitizeAndValidateDisplayName(displayName);
if (!result.success) {
const err = new Error(result.error);
(err as any).code = result.error;
throw err;
}
return result.data;
}
20 changes: 9 additions & 11 deletions src/modules/creator/creator-list-page.guard.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { strict as assert } from 'assert';
import { normalizeCreatorListPage } from './creator-list-page.guard';

function run() {
assert.equal(normalizeCreatorListPage(undefined), 1);
assert.equal(normalizeCreatorListPage('abc'), 1);
assert.equal(normalizeCreatorListPage(-5), 1);
assert.equal(normalizeCreatorListPage(999, { max: 100 }), 100);
assert.equal(normalizeCreatorListPage(2), 2);

console.log('creator-list-page.guard tests passed');
}

run();
describe('normalizeCreatorListPage', () => {
it('normalizes creator list page parameters', () => {
assert.equal(normalizeCreatorListPage(undefined), 1);
assert.equal(normalizeCreatorListPage('abc'), 1);
assert.equal(normalizeCreatorListPage(-5), 1);
assert.equal(normalizeCreatorListPage(999, { max: 100 }), 100);
assert.equal(normalizeCreatorListPage(2), 2);
});
});
43 changes: 21 additions & 22 deletions src/modules/creator/creator-profile-update.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,23 @@ jest.mock('../../utils/wallet-ownership.utils', () => ({
}));

jest.mock('./creator-profile.service', () => ({
getCreatorProfile: jest.fn(
async (creatorId: string) => ({
creatorId,
displayName: UPDATED_DISPLAY_NAME,
bio: UPDATED_BIO,
avatarUrl: null,
createdAt: null,
updatedAt: null,
perks: [],
links: [],
currentPrice: null,
price24hAgo: null,
priceChange24h: null,
metadata: {
source: 'database',
isProfileComplete: true,
},
})
),
getCreatorProfile: jest.fn(async (creatorId: string) => ({
creatorId,
displayName: UPDATED_DISPLAY_NAME,
bio: UPDATED_BIO,
avatarUrl: null,
createdAt: null,
updatedAt: null,
perks: [],
links: [],
currentPrice: null,
price24hAgo: null,
priceChange24h: null,
metadata: {
source: 'database',
isProfileComplete: true,
},
})),
upsertCreatorProfile: jest.fn(
async (creatorId: string, payload: unknown) => ({
creatorId,
Expand Down Expand Up @@ -93,7 +91,8 @@ const mockedCheck =
const TEST_CREATOR_ID = 'creator-profile-update-id';
const UPDATED_DISPLAY_NAME = 'Updated Display Name';
const UPDATED_BIO = 'Updated bio content';
const OWNER_WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const OWNER_WALLET_ADDRESS =
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

describe('PUT /api/v1/creators/:creatorId/profile — display name and bio persistence', () => {
beforeEach(() => {
Expand Down Expand Up @@ -172,12 +171,12 @@ describe('PUT /api/v1/creators/:creatorId/profile — display name and bio persi
);
});

it('returns 400 when display name is too short', async () => {
it('returns 400 when display name is too long', async () => {
const res = await supertest(app)
.put(`/api/v1/creators/${TEST_CREATOR_ID}/profile`)
.set('x-wallet-address', OWNER_WALLET_ADDRESS)
.send({
displayName: 'A',
displayName: 'a'.repeat(51),
bio: 'Some bio',
});

Expand Down
80 changes: 39 additions & 41 deletions src/modules/creator/creator-profile.schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,45 @@
import { strict as assert } from 'assert';
import { CreatorProfileParamsSchema } from './creator-profile.schemas';

function run() {
const emptySlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: '',
});
assert.equal(emptySlugResult.success, false);
assert.deepEqual(
emptySlugResult.success ? [] : emptySlugResult.error.issues,
[
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['creatorId'],
message: 'Creator ID is required',
},
]
);
describe('CreatorProfileParamsSchema', () => {
it('validates creator profile params', () => {
const emptySlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: '',
});
assert.equal(emptySlugResult.success, false);
assert.deepEqual(
emptySlugResult.success ? [] : emptySlugResult.error.issues,
[
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['creatorId'],
message: 'Creator ID is required',
},
]
);

const whitespaceSlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: ' ',
});
assert.equal(whitespaceSlugResult.success, false);
assert.deepEqual(
whitespaceSlugResult.success ? [] : whitespaceSlugResult.error.issues,
[
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['creatorId'],
message: 'Creator ID is required',
},
]
);
const whitespaceSlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: ' ',
});
assert.equal(whitespaceSlugResult.success, false);
assert.deepEqual(
whitespaceSlugResult.success ? [] : whitespaceSlugResult.error.issues,
[
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['creatorId'],
message: 'Creator ID is required',
},
]
);

const validSlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: 'alice',
const validSlugResult = CreatorProfileParamsSchema.safeParse({
creatorId: 'alice',
});
assert.equal(validSlugResult.success, true);
});
assert.equal(validSlugResult.success, true);

console.log('creator-profile.schemas tests passed');
}

run();
});
7 changes: 4 additions & 3 deletions src/modules/creator/creator-profile.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';
import { withCreatorSlugEmptyStringNormalization } from './creator-slug-input.utils';
import { normalizeSocialLinkUrl } from './creator-social-link-url.utils';
import { sanitizeBio } from './creator-bio-sanitize.utils';
import { sanitizeDisplayName } from './creator-display-name-sanitize.utils';

/**
* Shared creator profile identifier schema for route params.
Expand Down Expand Up @@ -68,9 +69,9 @@ export const CreatorProfileReadResponseSchema = z.object({
export const UpsertCreatorProfileBodySchema = z.object({
displayName: z
.string()
.trim()
.min(2, 'Display name must be at least 2 characters')
.max(80, 'Display name must be at most 80 characters')
.transform(sanitizeDisplayName)
.refine(val => val.length > 0, { message: 'display_name_empty' })
.refine(val => val.length <= 50, { message: 'display_name_too_long' })
.optional(),
bio: z
.string()
Expand Down
Loading
Loading