Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.
Open
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
19 changes: 19 additions & 0 deletions src/validations/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {

/** Validate that given profile deployment includes a face256 thumbnail with valid size */
const defaultThumbnailSize = 256
const MIN_PROFILE_NAME_LENGTH = 2
const MAX_PROFILE_NAME_LENGTH = 15

export const isOldEmote = (wearable: string): boolean => /^[a-z]+$/i.test(wearable)

Expand Down Expand Up @@ -293,6 +295,23 @@ export async function entityShouldNotHaveContentFilesValidateFn(
return validateAfterADR290RejectedTimestamp(validateFn)(deployment)
}

export function profileNameValidateFn(deployment: DeploymentToValidate): ValidationResponse {
const allAvatars: Avatar[] = deployment.entity.metadata?.avatars ?? []
const errors: string[] = []
for (const avatar of allAvatars) {
const name = avatar.name
if (name.length < MIN_PROFILE_NAME_LENGTH || name.length > MAX_PROFILE_NAME_LENGTH) {
errors.push(
`Profile names should be between ${MIN_PROFILE_NAME_LENGTH} and ${MAX_PROFILE_NAME_LENGTH} characters.`
)
}
if (!/^[a-zA-Z0-9]+$/.test(name)) {
errors.push('Profile name should only contain letters and numbers. No special characters allowed.')
}
}
return fromErrors(...errors)
}

export function createProfileValidateFn(components: ContentValidatorComponents): ValidateFn {
/**
* Validate that given profile deployment includes the face256 file with the correct size
Expand Down
6 changes: 6 additions & 0 deletions src/validations/timestamps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,9 @@ export const ADR_290_REJECTED_TIMESTAMP = process.env.ADR_290_REJECTED_TIMESTAMP
* @public
*/
export const LEGACY_CONTENT_MIGRATION_TIMESTAMP = 1582167600000

/**
* 1765767600000 = 2025-12-15T00:00:00Z
* @public
*/
export const ADR_291_TIMESTAMP = process.env.ADR_291_TIMESTAMP ? parseInt(process.env.ADR_291_TIMESTAMP) : 1765767600000
5 changes: 5 additions & 0 deletions src/validations/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ADR_244_TIMESTAMP,
ADR_290_OPTIONAL_TIMESTAMP,
ADR_290_REJECTED_TIMESTAMP,
ADR_291_TIMESTAMP,
ADR_45_TIMESTAMP,
ADR_74_TIMESTAMP,
ADR_75_TIMESTAMP
Expand Down Expand Up @@ -93,3 +94,7 @@ export function validateUpToADR290OptionalityTimestamp(fromTimestamp: number, va
validate
)
}

export function validateAfterADR291(validate: ValidateFn): ValidateFn {
return validateIfConditionMet((deployment) => deployment.entity.timestamp >= ADR_291_TIMESTAMP, validate)
}
166 changes: 166 additions & 0 deletions test/unit/validations/profiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
entityShouldNotHaveContentFilesValidateFn,
profileMustHaveEmotesValidateFn,
profileMustNotHaveSnapshotsValidateFn,
profileNameValidateFn,
profileSlotsAreNotRepeatedValidateFn,
profileWearablesNotRepeatedValidateFn,
wearableUrnsValidateFn
Expand Down Expand Up @@ -1097,6 +1098,171 @@ describe('when validating that the entity should not have content files', () =>
})
})

describe('when validating profile name', () => {
let deployment: DeploymentToValidate

beforeEach(() => {
jest.clearAllMocks()

deployment = buildDeployment({
entity: buildProfileEntity({
timestamp: ADR_75_TIMESTAMP + 1000,
metadata: VALID_PROFILE_METADATA
})
})
})

describe('and the name is valid', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'ValidName123'
})

it('should return ok', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(true)
})
})

describe('and the name has minimum valid length', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'ab'
})

it('should return ok', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(true)
})
})

describe('and the name has maximum valid length', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'a'.repeat(15)
})

it('should return ok', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(true)
})
})

describe('and the name is only numbers', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = '123456'
})

it('should return ok', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(true)
})
})

describe('and the name is too short', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'a'
})

it('should return an error about invalid length', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain('Profile names should be between 2 and 15 characters.')
})
})

describe('and the name is empty', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = ''
})

it('should return an error about invalid length', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain('Profile names should be between 2 and 15 characters.')
})
})

describe('and the name is too long', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'a'.repeat(16)
})

it('should return an error about invalid length', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain('Profile names should be between 2 and 15 characters.')
})
})

describe('and the name contains spaces', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'Some Name'
})

it('should return an error about special characters', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain(
'Profile name should only contain letters and numbers. No special characters allowed.'
)
})
})

describe('and the name contains hyphens', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'name-test'
})

it('should return an error about special characters', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain(
'Profile name should only contain letters and numbers. No special characters allowed.'
)
})
})

describe('and the name contains underscores', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'name_test'
})

it('should return an error about special characters', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain(
'Profile name should only contain letters and numbers. No special characters allowed.'
)
})
})

describe('and the name contains special characters', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'name@test'
})

it('should return an error about special characters', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain(
'Profile name should only contain letters and numbers. No special characters allowed.'
)
})
})

describe('and the name contains emoji', () => {
beforeEach(() => {
deployment.entity.metadata.avatars[0].name = 'name😊'
})

it('should return an error about special characters', () => {
const result: ValidationResponse = profileNameValidateFn(deployment)
expect(result.ok).toBe(false)
expect(result.errors).toContain(
'Profile name should only contain letters and numbers. No special characters allowed.'
)
})
})
})

describe('when creating profile validate function', () => {
let validateFn: ReturnType<typeof createProfileValidateFn>
let deployment: DeploymentToValidate
Expand Down
34 changes: 34 additions & 0 deletions test/unit/validations/validations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ADR_244_TIMESTAMP,
ADR_290_OPTIONAL_TIMESTAMP,
ADR_290_REJECTED_TIMESTAMP,
ADR_291_TIMESTAMP,
ADR_45_TIMESTAMP,
ADR_74_TIMESTAMP,
ADR_75_TIMESTAMP
Expand All @@ -18,6 +19,7 @@ import {
validateAfterADR236,
validateAfterADR244,
validateAfterADR290RejectedTimestamp,
validateAfterADR291,
validateAfterADR45,
validateAfterADR74,
validateAfterADR75,
Expand Down Expand Up @@ -459,6 +461,38 @@ describe('when testing validation wrapper functions', () => {
})
})

describe('when validating after ADR 291', () => {
describe('and the timestamp is before ADR 291', () => {
let deployment: DeploymentToValidate

beforeEach(() => {
deployment = buildDeployment({ entity: buildEntity({ timestamp: ADR_291_TIMESTAMP - 1000 }) })
})

it('should return ok without calling the validation function', async () => {
const validateFn = validateAfterADR291(mockValidateFn)
const result = await validateFn(deployment)
expect(result).toEqual(OK)
expect(mockValidateFn).not.toHaveBeenCalled()
})
})

describe('and the timestamp is at or after ADR 291', () => {
let deployment: DeploymentToValidate

beforeEach(() => {
deployment = buildDeployment({ entity: buildEntity({ timestamp: ADR_291_TIMESTAMP + 1000 }) })
})

it('should call the validation function and return the result from the validation function', async () => {
const validateFn = validateAfterADR291(mockValidateFn)
const result = await validateFn(deployment)
expect(result).toEqual(resultFromMockValidateFn)
expect(mockValidateFn).toHaveBeenCalledWith(deployment)
})
})
})

describe('when validating up to ADR 290 optionality timestamp', () => {
const fromTimestamp = ADR_158_TIMESTAMP

Expand Down
Loading