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
41 changes: 41 additions & 0 deletions migration/1784000000000-WidenUserDataTin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Widens `user_data.tin` from varchar(256) to varchar(1024). The RealUnit multi-tax-residence
* declaration is stored as a JSON array of {country, tin} in this column; 256 characters is no
* longer enough from ~7 entries on (seven 11-digit TINs already serialize past 256). Widening a
* varchar length in Postgres is a catalog-only change with no table rewrite.
*
* down() fails loud if longer values already exist — that is intentional (fail-closed: do not
* silently truncate tax-residence snapshots).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class WidenUserDataTin1784000000000 {
name = 'WidenUserDataTin1784000000000'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Fail fast instead of head-of-queue-blocking every "user_data" write if the ALTER is
// contended at deploy time: the timeout aborts with a clear lock_timeout error instead of
// hanging app boot.
await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);
await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(1024)`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
// Fails loud if any row already stores more than 256 characters — intentional; do not
// silently truncate a multi-tax-residence snapshot.
await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);
await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(256)`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ export class UserData extends IEntity {
@Column({ type: 'timestamp', nullable: true })
birthday?: Date;

@Column({ length: 256, nullable: true })
// RealUnit stores a JSON list of tax residences here (not a single TIN); 1024 covers the DTO-bounded max.
@Column({ length: 1024, nullable: true })
tin?: string;

// --- ORGANIZATION DATA --- //
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,59 @@ describe('RealUnitRegistrationDto (class-validator decorators)', () => {
expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined();
});

// When swissTaxResidence is true BUT countryAndTINs is present, validators must still run
// (previously @ValidateIf skipped @IsArray and a non-array body reached the service as a 500).
it('rejects a non-array countryAndTINs when swissTaxResidence is true (F3 regression)', async () => {
const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: 'pwned' }));
const tinError = errors.find((e) => e.property === 'countryAndTINs');
expect(tinError).toBeDefined();
expect(tinError!.constraints).toHaveProperty('isArray');
});

it('rejects a TIN longer than 64 characters via @MaxLength', async () => {
const errors = await validate(
build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 'x'.repeat(65) }] }),
);
const tinError = errors.find((e) => e.property === 'countryAndTINs');
expect(tinError).toBeDefined();
expect(tinError!.children?.length).toBeGreaterThan(0);
const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin');
expect(nestedTinError?.constraints).toHaveProperty('maxLength');
});

it('rejects more than 10 countryAndTINs entries via @ArrayMaxSize', async () => {
// Valid 2-letter codes so only ArrayMaxSize (not nested Matches) is the failure mode.
const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL'];
const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` }));
const errors = await validate(build({ swissTaxResidence: false, countryAndTINs }));
const tinError = errors.find((e) => e.property === 'countryAndTINs');
expect(tinError).toBeDefined();
expect(tinError!.constraints).toHaveProperty('arrayMaxSize');
});

it('trims TIN whitespace via string-safe @Transform', () => {
const dto = build({ swissTaxResidence: false, countryAndTINs: [{ country: 'DE', tin: ' DE123 ' }] });
expect(dto.countryAndTINs![0].tin).toBe('DE123');
});

// Non-string tin must not crash @Transform (Util.trim would throw TypeError => HTTP 500);
// the value must fall through so @IsString can reject it as a clean 400.
it('rejects a non-string tin as isString without throwing (400 not 500 regression)', async () => {
const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 12345 }] }));
const tinError = errors.find((e) => e.property === 'countryAndTINs');
expect(tinError).toBeDefined();
expect(tinError!.children?.length).toBeGreaterThan(0);
const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin');
expect(nestedTinError?.constraints).toHaveProperty('isString');
});

it('accepts exactly 10 countryAndTINs entries (ArrayMaxSize boundary)', async () => {
const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE'];
const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` }));
const errors = await validate(build({ swissTaxResidence: false, countryAndTINs }));
expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined();
});

// --- kycData nested factory (@Type(() => KycPersonalData)) --- //

it('instantiates kycData through the KycPersonalData type factory', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2216,10 +2216,26 @@ describe('RealUnitService', () => {

it('returns the idempotent result for an existing current-wallet registration', async () => {
userService.getUserByAddress.mockResolvedValue({
userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com' },
userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null },
} as any);
// After the F2 fix the isForCurrentWallet branch reconstructs the registration via
// toRegistrationDto (reads signedPayloadData) before syncing user_data.tin from the record.
jest.spyOn(service as any, 'findRegistration').mockResolvedValue({
registration: { id: 3, signature: '0xsig', status: ReviewStatus.COMPLETED },
registration: {
id: 3,
signature: '0xsig',
status: ReviewStatus.COMPLETED,
signedPayloadData: {
email: 'max@example.com',
name: 'Max',
walletAddress: '0xabc',
signature: '0xsig',
registrationDate: '2026-01-01',
swissTaxResidence: true,
addressCountry: 'CH',
},
kycDataObj: { accountType: 'Personal' },
},
isForCurrentWallet: true,
});

Expand Down Expand Up @@ -2305,6 +2321,111 @@ describe('RealUnitService', () => {

expect(status).toBe(RealUnitRegistrationStatus.FORWARDING_FAILED);
});

it('F1: does not call forwardRegistration when countryAndTINs is too large', async () => {
// Exercise the real tax-residence guard (serialized-length fail-closed) without the full EIP-712 path.
jest.spyOn(service as any, 'validateRegistrationDto').mockImplementation(async (d: any) => {
(service as any).validateTaxResidenceCoversAddress(d);
});
jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025));
const forwardSpy = jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true);

const oversizedDto: any = {
walletAddress: '0xabc',
signature: '0xsig',
email: 'max@example.com',
addressCountry: 'DE',
swissTaxResidence: false,
countryAndTINs: [{ country: 'DE', tin: 'DE123456789' }],
kycData: {},
};
userService.getUserByAddress.mockResolvedValue({
userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', firstname: 'Max' },
} as any);

await expect(service.completeRegistration(1, oversizedDto)).rejects.toThrow(/countryAndTINs is too large/);
expect(forwardSpy).not.toHaveBeenCalled();
});

it('F2: does not write user_data.tin when the idempotent path rejects a signature mismatch', async () => {
userService.getUserByAddress.mockResolvedValue({
userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null },
} as any);
jest.spyOn(service as any, 'findRegistration').mockResolvedValue({
registration: {
id: 3,
signature: '0xSTORED_SIGNATURE',
status: ReviewStatus.COMPLETED,
signedPayloadData: {
email: 'max@example.com',
name: 'Max',
walletAddress: '0xabc',
signature: '0xSTORED_SIGNATURE',
registrationDate: '2026-01-01',
swissTaxResidence: true,
addressCountry: 'CH',
countryAndTINs: [{ country: 'DE', tin: 'DE-STORED' }],
},
kycDataObj: { accountType: 'Personal' },
},
isForCurrentWallet: true,
});
// Incoming signature does NOT match the stored one; body carries a different TIN set.
const mismatchDto: any = {
...dto,
signature: '0xMISMATCHING_SIGNATURE',
countryAndTINs: [{ country: 'FR', tin: 'FR-ATTACKER' }],
};

await expect(service.completeRegistration(1, mismatchDto)).rejects.toThrow(BadRequestException);

const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls
.map((c) => c[1])
.filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin'));
expect(tinWrites).toHaveLength(0);
expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ subsystem: 'UserDataTin' }));
});

it('F2: syncs user_data.tin from the stored registration, not the request body, on idempotent retry', async () => {
const storedTins = [{ country: 'DE', tin: 'DE-STORED' }];
const bodyTins = [{ country: 'FR', tin: 'FR-FROM-BODY' }];
const userData: any = {
id: 1,
kycLevel: KycLevel.LEVEL_10,
mail: 'max@example.com',
tin: null,
};
userService.getUserByAddress.mockResolvedValue({ userData } as any);
jest.spyOn(service as any, 'findRegistration').mockResolvedValue({
registration: {
id: 3,
signature: '0xsig',
status: ReviewStatus.COMPLETED,
signedPayloadData: {
email: 'max@example.com',
name: 'Max',
walletAddress: '0xabc',
signature: '0xsig',
registrationDate: '2026-01-01',
swissTaxResidence: true,
addressCountry: 'CH',
countryAndTINs: storedTins,
},
kycDataObj: { accountType: 'Personal' },
},
isForCurrentWallet: true,
});
logService.create.mockResolvedValue({} as any);

const status = await service.completeRegistration(1, { ...dto, countryAndTINs: bodyTins });

expect(status).toBe(RealUnitRegistrationStatus.ALREADY_REGISTERED);
const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls
.map((c) => c[1])
.filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin'));
expect(tinWrites).toEqual([{ tin: JSON.stringify(storedTins) }]);
expect(tinWrites[0].tin).not.toContain('FR-FROM-BODY');
});
});

describe('ponder queries (GraphQL injection protection)', () => {
Expand Down Expand Up @@ -3205,6 +3326,55 @@ describe('RealUnitService', () => {
);
});

// --- F1/F3 defense-in-depth bounds on countryAndTINs (fail closed before forward) ---

it('F1: rejects countryAndTINs whose serialized length exceeds MAX_SERIALIZED_TIN_LENGTH', async () => {
// Under the DTO bounds the worst-case serialization is ~890 chars; the serialized-length
// check is a fail-closed safety net for constant drift. Force it by stubbing serialize.
const dto = attachTins(
await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()),
[{ country: 'DE', tin: 'DE123456789' }],
);
jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025));
await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/countryAndTINs is too large/);
});

it('F1: rejects more than 10 countryAndTINs entries at the service layer', async () => {
const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL'];
const dto = attachTins(
await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()),
countries.map((country, i) => ({ country, tin: `TIN${i}` })),
);
await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/more than 10 entries/);
});

it('F1: rejects a TIN longer than 64 characters at the service layer', async () => {
const dto = attachTins(
await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()),
[{ country: 'DE', tin: 'x'.repeat(65) }],
);
await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/must not exceed 64 characters/);
});

it('F3: rejects a non-array countryAndTINs with BadRequestException (not TypeError)', () => {
for (const countryAndTINs of ['pwned', { a: 1 }, 42] as any[]) {
expect(() =>
(service as any).validateTaxResidenceCoversAddress({
swissTaxResidence: true,
addressCountry: 'CH',
countryAndTINs,
}),
).toThrow(BadRequestException);
expect(() =>
(service as any).validateTaxResidenceCoversAddress({
swissTaxResidence: true,
addressCountry: 'CH',
countryAndTINs,
}),
).toThrow(/countryAndTINs must be an array/);
}
});

it('resolveSignedRegistrationMessage normalizes a signature that lacks the 0x prefix', async () => {
const fields = humanFields();
const signature = await wallet._signTypedData(domain, types, fields);
Expand Down Expand Up @@ -3612,6 +3782,33 @@ describe('RealUnitService', () => {
country: { symbol: 'CH', id: 7 },
});
});

// F4: malformed / contract-violating user_data.tin must degrade the prefill loudly, never throw.
it('F4: degrades prefill to empty countryAndTINs when tin is not valid JSON', () => {
const userData: any = { id: 99, firstname: 'Erika', tin: 'not json' };
const dto = (service as any).toUserDataDtoFromUserData(userData);
expect(dto).toBeDefined();
expect(dto.countryAndTINs).toBeUndefined();
expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not valid JSON'));
});

it('F4: degrades prefill to empty countryAndTINs when tin is a non-array JSON value', () => {
const userData: any = { id: 99, firstname: 'Erika', tin: '{"a":1}' };
const dto = (service as any).toUserDataDtoFromUserData(userData);
expect(dto).toBeDefined();
expect(dto.countryAndTINs).toBeUndefined();
expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not an array'));
});

it('F4: drops a contract-violating CH entry from the prefill without throwing', () => {
const userData: any = { id: 99, firstname: 'Erika', tin: '[{"country":"CH","tin":"x"}]' };
const dto = (service as any).toUserDataDtoFromUserData(userData);
expect(dto).toBeDefined();
expect(dto.countryAndTINs).toBeUndefined();
expect((service as any).logger.error).toHaveBeenCalledWith(
expect.stringContaining('violating the registration contract'),
);
});
});

describe('isPersonalDataMatching (organization account branch)', () => {
Expand Down
Loading