249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
import { expect, test } from 'vitest';
|
|
import { binToHex, generateRandomBytes, hexToBin } from '@bitauth/libauth';
|
|
import {
|
|
COMMIT_IDENTIFIER_BYTE_LENGTH,
|
|
INVITATION_IDENTIFIER_BYTE_LENGTH,
|
|
TEMPLATE_IDENTIFIER_BYTE_LENGTH,
|
|
TOKEN_CATEGORY_BYTE_LENGTH,
|
|
TRANSACTION_HASH_BYTE_LENGTH,
|
|
} from '@xo-cash/primitives';
|
|
import type { XOInvitation } from '@xo-cash/types';
|
|
import {
|
|
InvitationInvalidError,
|
|
InvitationJsonMalformedError,
|
|
InvitationSerializationFailedError,
|
|
deserializeInvitation,
|
|
parseInvitation,
|
|
serializeInvitation,
|
|
} from '../source/index.ts';
|
|
|
|
const invitationIdentifier = binToHex(generateRandomBytes(INVITATION_IDENTIFIER_BYTE_LENGTH));
|
|
const templateIdentifier = binToHex(generateRandomBytes(TEMPLATE_IDENTIFIER_BYTE_LENGTH));
|
|
const commitIdentifier = binToHex(generateRandomBytes(COMMIT_IDENTIFIER_BYTE_LENGTH));
|
|
const outpointTransactionHash = binToHex(generateRandomBytes(TRANSACTION_HASH_BYTE_LENGTH));
|
|
const tokenCategory = binToHex(generateRandomBytes(TOKEN_CATEGORY_BYTE_LENGTH));
|
|
|
|
// Valid invitation
|
|
const buildValidInvitation = (): XOInvitation => {
|
|
return {
|
|
invitationIdentifier,
|
|
createdAtTimestamp: 1700000000000,
|
|
templateIdentifier,
|
|
actionIdentifier: 'receive',
|
|
commits: [
|
|
{
|
|
commitIdentifier,
|
|
previousCommitIdentifier: undefined,
|
|
entityIdentifier: 'xpub661MyMwAqRbcEngHLkoUWCKMBMGeqdqCGkqtzrHaTZub9ALw2oRfHA6fQP5n5X9VHStaNTBYomkSb8BFhUGavwD3RG1qvMkEKceTavTp2Tm',
|
|
createdAtTimestamp: 1700000000000,
|
|
data: {
|
|
transactionVersion: 2,
|
|
transactionLocktime: 0,
|
|
variables: [{ variableIdentifier: 'amount', value: 18014398509481985n, roleIdentifier: 'sender' }],
|
|
inputs: [
|
|
{
|
|
outpointTransactionHash: hexToBin(outpointTransactionHash),
|
|
outpointIndex: 0,
|
|
sequenceNumber: 0,
|
|
inputIdentifier: 'fundingInput',
|
|
roleIdentifier: 'sender',
|
|
},
|
|
],
|
|
outputs: [
|
|
{
|
|
outputIdentifier: 'receiveOutput',
|
|
valueSatoshis: 1000n,
|
|
lockingBytecode: hexToBin('76a914000000000000000000000000000000000000000088ac'),
|
|
token: { category: hexToBin(tokenCategory), amount: 21n },
|
|
roleIdentifier: 'receiver',
|
|
},
|
|
],
|
|
},
|
|
signature:
|
|
'5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b',
|
|
expiresAtTimestamp: 1700003600000,
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Tests that parseInvitation accepts a valid invitation and returns the same object it received.
|
|
*/
|
|
const testParseInvitationReturnsReceivedObject = (): void => {
|
|
const invitation = buildValidInvitation();
|
|
|
|
expect(parseInvitation(invitation)).toBe(invitation);
|
|
};
|
|
|
|
/**
|
|
* Tests that an invitation survives a serialize and deserialize round trip.
|
|
*/
|
|
const testSerializeInvitationRoundTripsBigintAndBytes = (): void => {
|
|
const invitation = buildValidInvitation();
|
|
|
|
const restoredInvitation = deserializeInvitation(serializeInvitation(invitation));
|
|
|
|
expect(restoredInvitation).toEqual(invitation);
|
|
|
|
const [ restoredCommit ] = restoredInvitation.commits;
|
|
expect(restoredCommit!.data.variables![0]!.value).toBe(18014398509481985n);
|
|
expect(restoredCommit!.data.outputs![0]!.valueSatoshis).toBe(1000n);
|
|
expect(restoredCommit!.data.outputs![0]!.lockingBytecode).toBeInstanceOf(Uint8Array);
|
|
expect(binToHex(restoredCommit!.data.inputs![0]!.outpointTransactionHash!)).toBe(outpointTransactionHash);
|
|
};
|
|
|
|
/**
|
|
* Tests that parseInvitation throws InvitationInvalidError listing every missing required field.
|
|
*/
|
|
const testParseInvitationThrowsOnMissingRequiredFields = (): void => {
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
parseInvitation({});
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
|
|
|
const expectedMessage =
|
|
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
|
|
+ '- invitationIdentifier: expected string, received undefined\n'
|
|
+ '- createdAtTimestamp: expected number, received undefined\n'
|
|
+ '- templateIdentifier: expected string, received undefined\n'
|
|
+ '- actionIdentifier: expected string, received undefined\n'
|
|
+ '- commits: expected array, received undefined';
|
|
|
|
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that parseInvitation throws InvitationInvalidError reporting every field with a wrong type or out of range value.
|
|
*/
|
|
const testParseInvitationThrowsOnFieldTypeViolations = (): void => {
|
|
const invitationWithWrongFieldTypes = { ...buildValidInvitation(), createdAtTimestamp: -1, commits: 'none' };
|
|
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
parseInvitation(invitationWithWrongFieldTypes);
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
|
|
|
const expectedMessage =
|
|
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
|
|
+ '- createdAtTimestamp: Too small: expected number to be >=0\n'
|
|
+ '- commits: expected array, received string';
|
|
|
|
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that parseInvitation throws InvitationInvalidError for an unrecognized property at the top level.
|
|
*/
|
|
const testParseInvitationThrowsOnUnknownProperties = (): void => {
|
|
const invitationWithUnknownProperty = { ...buildValidInvitation(), unknownProperty: 'unexpected' };
|
|
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
parseInvitation(invitationWithUnknownProperty);
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
|
|
|
const expectedMessage =
|
|
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- (root): Unrecognized key: "unknownProperty"';
|
|
|
|
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that parseInvitation throws InvitationInvalidError with the full path of an unrecognized property inside commit data.
|
|
*/
|
|
const testParseInvitationThrowsOnDeepUnknownProperties = (): void => {
|
|
const invitation = buildValidInvitation();
|
|
const [ firstCommit ] = invitation.commits;
|
|
const invitationWithDeepUnknown = { ...invitation, commits: [{ ...firstCommit, data: { ...firstCommit!.data, unknownData: 1 } }] };
|
|
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
parseInvitation(invitationWithDeepUnknown);
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
|
|
|
const expectedMessage =
|
|
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- commits.0.data: Unrecognized key: "unknownData"';
|
|
|
|
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that deserializeInvitation throws InvitationJsonMalformedError for text that is not valid JSON.
|
|
*/
|
|
const testDeserializeInvitationThrowsOnMalformedJson = (): void => {
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
deserializeInvitation('{"invitationIdentifier":');
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationJsonMalformedError);
|
|
|
|
const expectedMessage = 'Invitation JSON malformed: expected a valid JSON string, but got: Unexpected end of JSON input';
|
|
|
|
expect((thrownError as InvitationJsonMalformedError).message).toBe(expectedMessage);
|
|
expect((thrownError as InvitationJsonMalformedError).reason).toBe('Unexpected end of JSON input');
|
|
};
|
|
|
|
/**
|
|
* Tests that serializeInvitation throws InvitationSerializationFailedError when the value cannot be written as JSON.
|
|
*/
|
|
const testSerializeInvitationThrowsOnCircularValue = (): void => {
|
|
// A value that refers to itself has no JSON form
|
|
const circularInvitation: Record<string, unknown> = { ...buildValidInvitation() };
|
|
circularInvitation.self = circularInvitation;
|
|
|
|
let thrownError: unknown;
|
|
|
|
try {
|
|
serializeInvitation(circularInvitation as XOInvitation);
|
|
} catch (caughtError) {
|
|
thrownError = caughtError;
|
|
}
|
|
|
|
expect(thrownError).toBeInstanceOf(InvitationSerializationFailedError);
|
|
|
|
const expectedMessage =
|
|
'Invitation serialization failed: Converting circular structure to JSON\n'
|
|
+ " --> starting at object with constructor 'Object'\n"
|
|
+ " --- property 'self' closes the circle";
|
|
|
|
expect((thrownError as InvitationSerializationFailedError).message).toBe(expectedMessage);
|
|
};
|
|
|
|
const runTests = async (): Promise<void> => {
|
|
test('parseInvitation: returns the received object for a valid invitation', testParseInvitationReturnsReceivedObject);
|
|
test('serializeInvitation: round trips bigint and Uint8Array fields', testSerializeInvitationRoundTripsBigintAndBytes);
|
|
test('parseInvitation: throws InvitationInvalidError on missing required fields', testParseInvitationThrowsOnMissingRequiredFields);
|
|
test('parseInvitation: throws InvitationInvalidError on field type violations', testParseInvitationThrowsOnFieldTypeViolations);
|
|
test('parseInvitation: throws InvitationInvalidError on unknown top-level property', testParseInvitationThrowsOnUnknownProperties);
|
|
test('parseInvitation: throws InvitationInvalidError on unknown property inside commit data', testParseInvitationThrowsOnDeepUnknownProperties);
|
|
test('deserializeInvitation: throws InvitationJsonMalformedError on malformed JSON', testDeserializeInvitationThrowsOnMalformedJson);
|
|
test('serializeInvitation: throws InvitationSerializationFailedError on a circular value', testSerializeInvitationThrowsOnCircularValue);
|
|
};
|
|
|
|
await runTests();
|