Skip to content

Commit

Permalink
Migrate Message/MessageConfirmation to zod (#1315)
Browse files Browse the repository at this point in the history
This migrates the validation of `Message` to `zod`:

- Remove `MessageValidator` and associated schemas.
- Add test-covered `MessageSchema`/`MessageConfirmationSchema` and infer types from them.
- Propagate type requirements.
- Update tests accordingly.
  • Loading branch information
iamacook committed Mar 22, 2024
1 parent f7d6208 commit edc276d
Show file tree
Hide file tree
Showing 12 changed files with 284 additions and 146 deletions.
2 changes: 0 additions & 2 deletions src/domain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import { IEstimationsRepository } from '@/domain/estimations/estimations.reposit
import { EstimationsRepository } from '@/domain/estimations/estimations.repository';
import { MessagesRepository } from '@/domain/messages/messages.repository';
import { IMessagesRepository } from '@/domain/messages/messages.repository.interface';
import { MessageValidator } from '@/domain/messages/message.validator';
import { IHealthRepository } from '@/domain/health/health.repository.interface';
import { HealthRepository } from '@/domain/health/health.repository';
import { HumanDescriptionApiModule } from '@/datasources/human-description-api/human-description-api.module';
Expand Down Expand Up @@ -67,7 +66,6 @@ import { BalancesApiModule } from '@/datasources/balances-api/balances-api.modul
{ provide: ISafeRepository, useClass: SafeRepository },
{ provide: ITokenRepository, useClass: TokenRepository },
DataDecodedValidator,
MessageValidator,
MultisigTransactionValidator,
TransactionTypeValidator,
TransferValidator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import {
MessageConfirmation,
SignatureType,
} from '@/domain/messages/entities/message-confirmation.entity';
import { getAddress } from 'viem';

export function messageConfirmationBuilder(): IBuilder<MessageConfirmation> {
return new Builder<MessageConfirmation>()
.with('created', faker.date.recent())
.with('modified', faker.date.recent())
.with('owner', faker.finance.ethereumAddress())
.with('signature', faker.string.hexadecimal({ length: 32 }))
.with('owner', getAddress(faker.finance.ethereumAddress()))
.with(
'signature',
faker.string.hexadecimal({ length: 32 }) as `0x${string}`,
)
.with('signatureType', faker.helpers.objectValue(SignatureType));
}

Expand Down
15 changes: 11 additions & 4 deletions src/domain/messages/entities/__tests__/message.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,30 @@ import {
messageConfirmationBuilder,
toJson as messageConfirmationToJson,
} from '@/domain/messages/entities/__tests__/message-confirmation.builder';
import { getAddress } from 'viem';

export function messageBuilder(): IBuilder<Message> {
return new Builder<Message>()
.with('created', faker.date.recent())
.with('modified', faker.date.recent())
.with('safe', faker.finance.ethereumAddress())
.with('safe', getAddress(faker.finance.ethereumAddress()))
.with('message', faker.word.words({ count: { min: 1, max: 5 } }))
.with('messageHash', faker.string.hexadecimal({ length: 32 }))
.with('proposedBy', faker.finance.ethereumAddress())
.with(
'messageHash',
faker.string.hexadecimal({ length: 32 }) as `0x${string}`,
)
.with('proposedBy', getAddress(faker.finance.ethereumAddress()))
.with('safeAppId', faker.number.int())
.with(
'confirmations',
faker.helpers.multiple(() => messageConfirmationBuilder().build(), {
count: { min: 2, max: 5 },
}),
)
.with('preparedSignature', faker.string.hexadecimal({ length: 32 }));
.with(
'preparedSignature',
faker.string.hexadecimal({ length: 32 }) as `0x${string}`,
);
}

export function toJson(message: Message): unknown {
Expand Down
11 changes: 4 additions & 7 deletions src/domain/messages/entities/message-confirmation.entity.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { MessageConfirmationSchema } from '@/domain/messages/entities/schemas/message.schema';
import { z } from 'zod';

export enum SignatureType {
ContractSignature = 'CONTRACT_SIGNATURE',
ApprovedHash = 'APPROVED_HASH',
Eoa = 'EOA',
EthSign = 'ETH_SIGN',
}

export interface MessageConfirmation {
created: Date;
modified: Date;
owner: string;
signature: string;
signatureType: SignatureType;
}
export type MessageConfirmation = z.infer<typeof MessageConfirmationSchema>;
15 changes: 3 additions & 12 deletions src/domain/messages/entities/message.entity.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import { MessageConfirmation } from '@/domain/messages/entities/message-confirmation.entity';
import { MessageSchema } from '@/domain/messages/entities/schemas/message.schema';
import { z } from 'zod';

export interface Message {
created: Date;
modified: Date;
safe: string;
messageHash: string;
message: string | unknown;
proposedBy: string;
safeAppId: number | null;
confirmations: MessageConfirmation[];
preparedSignature: string | null;
}
export type Message = z.infer<typeof MessageSchema>;
220 changes: 220 additions & 0 deletions src/domain/messages/entities/schemas/__tests__/message.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { fakeJson } from '@/__tests__/faker';
import { pageBuilder } from '@/domain/entities/__tests__/page.builder';
import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder';
import { messageBuilder } from '@/domain/messages/entities/__tests__/message.builder';
import { SignatureType } from '@/domain/messages/entities/message-confirmation.entity';
import {
MessageConfirmationSchema,
MessagePageSchema,
MessageSchema,
} from '@/domain/messages/entities/schemas/message.schema';
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import { ZodError } from 'zod';

describe('Message schemas', () => {
describe('MessageConfirmationSchema', () => {
it('should validate a valid MessageConfirmation', () => {
const messageConfirmation = messageConfirmationBuilder().build();

const result = MessageConfirmationSchema.safeParse(messageConfirmation);

expect(result.success).toBe(true);
});

it.each(['created' as const, 'modified' as const])(
'should coerce %s to a date',
(key) => {
const messageConfirmation = messageConfirmationBuilder()
.with(key, faker.date.recent().toISOString() as unknown as Date)
.build();

const result = MessageConfirmationSchema.safeParse(messageConfirmation);

expect(result.success && result.data[key]).toStrictEqual(
new Date(messageConfirmation[key]),
);
},
);

it('should checksum the owner', () => {
const nonChecksummedAddress = faker.finance
.ethereumAddress()
.toLowerCase() as `0x${string}`;
const messageConfirmation = messageConfirmationBuilder()
.with('owner', nonChecksummedAddress)
.build();

const result = MessageConfirmationSchema.safeParse(messageConfirmation);

expect(result.success && result.data.owner).toBe(
getAddress(nonChecksummedAddress),
);
});

it('should not allow non-hex signature', () => {
const messageConfirmation = messageConfirmationBuilder()
.with('signature', faker.string.numeric() as `0x${string}`)
.build();

const result = MessageConfirmationSchema.safeParse(messageConfirmation);

expect(!result.success && result.error).toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'Invalid input',
path: ['signature'],
},
]),
);
});

it('should not allow invalid signature types', () => {
const messageConfirmation = messageConfirmationBuilder()
.with('signatureType', faker.lorem.word() as SignatureType)
.build();

const result = MessageConfirmationSchema.safeParse(messageConfirmation);

expect(!result.success && result.error).toStrictEqual(
new ZodError([
{
received: messageConfirmation.signatureType,
code: 'invalid_enum_value',
options: ['CONTRACT_SIGNATURE', 'APPROVED_HASH', 'EOA', 'ETH_SIGN'],
path: ['signatureType'],
message: `Invalid enum value. Expected 'CONTRACT_SIGNATURE' | 'APPROVED_HASH' | 'EOA' | 'ETH_SIGN', received '${messageConfirmation.signatureType}'`,
},
]),
);
});
});

describe('MessageSchema', () => {
it('should validate a valid Message', () => {
const message = messageBuilder().build();

const result = MessageSchema.safeParse(message);

expect(result.success).toBe(true);
});

it.each(['created' as const, 'modified' as const])(
'should coerce %s to a date',
(key) => {
const message = messageBuilder().build();

const result = MessageSchema.safeParse(message);

expect(result.success && result.data[key]).toStrictEqual(
new Date(message[key]),
);
},
);

it.each(['safe' as const, 'proposedBy' as const])(
'should checksum the %s',
(key) => {
const nonChecksummedAddress = faker.finance
.ethereumAddress()
.toLowerCase() as `0x${string}`;
const message = messageBuilder()
.with(key, nonChecksummedAddress)
.build();

const result = MessageSchema.safeParse(message);

expect(result.success && result.data[key]).toBe(
getAddress(nonChecksummedAddress),
);
},
);

it.each(['messageHash' as const, 'preparedSignature' as const])(
'should not allow non-hex %s',
(key) => {
const message = messageBuilder()
.with(key, faker.string.numeric() as `0x${string}`)
.build();

const result = MessageSchema.safeParse(message);

expect(!result.success && result.error).toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'Invalid input',
path: [key],
},
]),
);
},
);

it.each([
['string', faker.lorem.sentence()],
['object', JSON.parse(fakeJson())],
])('should allow a %s message', (_, message) => {
const result = MessageSchema.safeParse({
...messageBuilder().build(),
message,
});

expect(result.success).toBe(true);
});

it.each(['safeAppId' as const, 'preparedSignature' as const])(
'should allow undefined %s, defaulting to null',
(key) => {
const message = messageBuilder().build();
delete message[key];

const result = MessageSchema.safeParse(message);

expect(result.success && result.data[key]).toBe(null);
},
);

it('should allow empty confirmations', () => {
const message = messageBuilder().with('confirmations', []).build();

const result = MessageSchema.safeParse(message);

expect(result.success).toBe(true);
});

it.each([
'created' as const,
'modified' as const,
'safe' as const,
'messageHash' as const,
'message' as const,
'proposedBy' as const,
'confirmations' as const,
])('should not allow %s to be undefined', (key) => {
const message = messageBuilder().build();
delete message[key];

const result = MessageSchema.safeParse(message);

expect(
!result.success &&
result.error.issues.length === 1 &&
result.error.issues[0].path.length === 1 &&
result.error.issues[0].path[0] === key,
).toBe(true);
});
});

describe('MessagePageSchema', () => {
it('should validate a valid Page<Message>', () => {
const message = messageBuilder().build();
const messagePage = pageBuilder().with('results', [message]).build();

const result = MessagePageSchema.safeParse(messagePage);

expect(result.success).toBe(true);
});
});
});

0 comments on commit edc276d

Please sign in to comment.