From fb5d55ece6e668653c93045340475baf6e1fdcdb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:50:56 +0200 Subject: [PATCH 1/9] fix(auth): keep the login signature out of every entity query user.signature is a static login credential: for DeFiChain and custodial Lightning the stored value IS what authenticates a sign-in, so whoever reads it can sign in as that user. The column carried no select: false and no @Exclude, and there is no global ClassSerializerInterceptor. It therefore travelled out through every admin endpoint that returns an entity reaching it via a relation - reachable from the Support role upwards. Transaction.user is eager, so it also arrived where no relation had been requested, which is why filtering the known endpoints would have been structurally incomplete: the next endpoint returning a transaction would reopen it. Marking the column select: false removes it from every query by default, so it cannot leave through an entity at all. The single remaining read path is UserService.getSignature(), which fetches it explicitly for the comparison in doSignIn. Writing is unaffected. Covered by a metadata assertion that the column is select: false (verified by mutation: removing the option turns that test red) and, against a real Postgres, that findOne omits such a column, that addSelect returns it - the pattern getSignature() relies on - and that raw SQL still sees the value, so this is an ORM-level change and not a data migration. --- .../generic/user/models/auth/auth.service.ts | 6 +- .../signature-column-visibility.spec.ts | 148 ++++++++++++++++++ .../generic/user/models/user/user.entity.ts | 8 +- .../generic/user/models/user/user.service.ts | 14 ++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 258aa075b0..45d9851d8a 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -243,8 +243,12 @@ export class AuthService { private async doSignIn(user: User, dto: SignInDto & { wallet?: string }, userIp: string, isCustodial: boolean) { if (!user.custodyProvider || user.custodyProvider.masterKey !== dto.signature) { + // The column is `select: false` (it is a login credential), so the loaded entity does not carry + // it - fetch it explicitly for the comparison paths in verifySignature. + const dbSignature = await this.userService.getSignature(user.id); + if ( - !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, user.signature, dto.blockchain)) + !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, dbSignature, dto.blockchain)) ) { throw new UnauthorizedException('Invalid credentials'); } diff --git a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts new file mode 100644 index 0000000000..86e2646bc7 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts @@ -0,0 +1,148 @@ +import { + Column, + DataSource, + Entity, + getMetadataArgsStorage, + PrimaryGeneratedColumn, + QueryRunner, +} from 'typeorm'; +import { User } from '../user.entity'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'signature_column_visibility_spec'; + +describe('User signature column metadata', () => { + it('marks signature with select: false so TypeORM excludes it from default queries', () => { + const column = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'signature', + ); + + expect(column).toBeDefined(); + expect(column.options.select).toBe(false); + }); + + it('does not mark address with select: false (control for metadata reading)', () => { + const column = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'address', + ); + + expect(column).toBeDefined(); + expect(column.options.select).toBeUndefined(); + }); +}); + +// Minimal probe entity: `hidden` mirrors User.signature column options +// (`type: 'text', nullable: true, select: false`) so this block exercises the +// ORM mechanism UserService.getSignature() relies on — not the User entity itself +// (too many relations for an isolated DataSource). +@Entity({ name: 'select_false_probe' }) +class SelectFalseProbe { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'text' }) + visible: string; + + @Column({ type: 'text', nullable: true, select: false }) + hidden?: string; +} + +describeDb('select: false runtime behaviour (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: PG_URL, + entities: [SelectFalseProbe], + schema: SCHEMA, + synchronize: false, + }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + + await queryRunner.query(` + CREATE TABLE "select_false_probe" ( + "id" SERIAL PRIMARY KEY, + "visible" text NOT NULL, + "hidden" text + ) + `); + }); + + afterEach(async () => { + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('findOne omits a select: false column from the loaded entity', async () => { + const inserted = (await queryRunner.query( + `INSERT INTO "select_false_probe" ("visible", "hidden") + VALUES ('visible-value', 'hidden-secret') + RETURNING "id"`, + )) as { id: number }[]; + const id = inserted[0].id; + + const row = await dataSource.getRepository(SelectFalseProbe).findOne({ where: { id } }); + + expect(row).toBeDefined(); + expect(row.visible).toBe('visible-value'); + expect(row.hidden).toBeUndefined(); + }); + + it( + 'addSelect returns a select: false column ' + + '(the pattern UserService.getSignature() uses)', + async () => { + const inserted = (await queryRunner.query( + `INSERT INTO "select_false_probe" ("visible", "hidden") + VALUES ('visible-value', 'hidden-secret') + RETURNING "id"`, + )) as { id: number }[]; + const id = inserted[0].id; + + const row = await dataSource + .getRepository(SelectFalseProbe) + .createQueryBuilder('probe') + .select('probe.id') + .addSelect('probe.hidden') + .where('probe.id = :id', { id }) + .getOne(); + + expect(row).toBeDefined(); + expect(row.hidden).toBe('hidden-secret'); + }, + ); + + it('raw SQL still sees the value (select: false is ORM-level, not a data migration)', async () => { + const inserted = (await queryRunner.query( + `INSERT INTO "select_false_probe" ("visible", "hidden") + VALUES ('visible-value', 'hidden-secret') + RETURNING "id"`, + )) as { id: number }[]; + const id = inserted[0].id; + + const rows = (await queryRunner.query( + `SELECT "hidden" FROM "select_false_probe" WHERE "id" = $1`, + [id], + )) as { hidden: string }[]; + + expect(rows).toHaveLength(1); + expect(rows[0].hidden).toBe('hidden-secret'); + }); +}); diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 0699ce0e24..abdc15fcbb 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -28,7 +28,13 @@ export class User extends IEntity { @Column({ length: 256, nullable: true }) addressType?: UserAddressType; - @Column({ type: 'text', nullable: true }) + // Login credential: for DeFiChain and custodial Lightning the stored value IS the secret that + // authenticates a sign-in (see AuthService.verifySignature). `select: false` keeps it out of every + // query by default, so it can never travel out through an entity returned by a controller - several + // admin endpoints return entities that reach this column through relations, and `Transaction.user` + // is eager, so it arrives even where no relation was requested. Read it only through + // UserService.getSignature(), never by widening a query. + @Column({ type: 'text', nullable: true, select: false }) signature?: string; @Column({ length: 256, nullable: true }) diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 9200a9a2c7..16fcba628f 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -97,6 +97,20 @@ export class UserService { return this.userRepo.findOne({ where: { address }, relations }); } + // Deliberately the only read path for the login credential in `user.signature`: the column is + // `select: false`, so it never leaves through an entity. Keep this narrow - do not add the column + // to any other query. + async getSignature(userId: number): Promise { + const result = await this.userRepo + .createQueryBuilder('user') + .select('user.id') + .addSelect('user.signature') + .where('user.id = :userId', { userId }) + .getOne(); + + return result?.signature; + } + async getUserByKey(key: string, value: any, onlyDefaultRelation = false): Promise { const query = this.userRepo .createQueryBuilder('user') From 4db4f6dcff2221d6c2b36b6ebf357caeb9520cdb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:29:12 +0200 Subject: [PATCH 2/9] style(auth): apply repo formatting to the signature visibility spec Prettier collapses the split test title and the query chains. No behavioural change - the five cases assert exactly the same things and pass unchanged. --- .../signature-column-visibility.spec.ts | 62 +++++++------------ 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts index 86e2646bc7..232e34d0fc 100644 --- a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts @@ -1,11 +1,4 @@ -import { - Column, - DataSource, - Entity, - getMetadataArgsStorage, - PrimaryGeneratedColumn, - QueryRunner, -} from 'typeorm'; +import { Column, DataSource, Entity, getMetadataArgsStorage, PrimaryGeneratedColumn, QueryRunner } from 'typeorm'; import { User } from '../user.entity'; const PG_URL = process.env.MIGRATION_TEST_PG; @@ -14,18 +7,14 @@ const SCHEMA = 'signature_column_visibility_spec'; describe('User signature column metadata', () => { it('marks signature with select: false so TypeORM excludes it from default queries', () => { - const column = getMetadataArgsStorage().columns.find( - (c) => c.target === User && c.propertyName === 'signature', - ); + const column = getMetadataArgsStorage().columns.find((c) => c.target === User && c.propertyName === 'signature'); expect(column).toBeDefined(); expect(column.options.select).toBe(false); }); it('does not mark address with select: false (control for metadata reading)', () => { - const column = getMetadataArgsStorage().columns.find( - (c) => c.target === User && c.propertyName === 'address', - ); + const column = getMetadataArgsStorage().columns.find((c) => c.target === User && c.propertyName === 'address'); expect(column).toBeDefined(); expect(column.options.select).toBeUndefined(); @@ -105,29 +94,25 @@ describeDb('select: false runtime behaviour (real Postgres)', () => { expect(row.hidden).toBeUndefined(); }); - it( - 'addSelect returns a select: false column ' + - '(the pattern UserService.getSignature() uses)', - async () => { - const inserted = (await queryRunner.query( - `INSERT INTO "select_false_probe" ("visible", "hidden") + it('addSelect returns a select: false column ' + '(the pattern UserService.getSignature() uses)', async () => { + const inserted = (await queryRunner.query( + `INSERT INTO "select_false_probe" ("visible", "hidden") VALUES ('visible-value', 'hidden-secret') RETURNING "id"`, - )) as { id: number }[]; - const id = inserted[0].id; - - const row = await dataSource - .getRepository(SelectFalseProbe) - .createQueryBuilder('probe') - .select('probe.id') - .addSelect('probe.hidden') - .where('probe.id = :id', { id }) - .getOne(); - - expect(row).toBeDefined(); - expect(row.hidden).toBe('hidden-secret'); - }, - ); + )) as { id: number }[]; + const id = inserted[0].id; + + const row = await dataSource + .getRepository(SelectFalseProbe) + .createQueryBuilder('probe') + .select('probe.id') + .addSelect('probe.hidden') + .where('probe.id = :id', { id }) + .getOne(); + + expect(row).toBeDefined(); + expect(row.hidden).toBe('hidden-secret'); + }); it('raw SQL still sees the value (select: false is ORM-level, not a data migration)', async () => { const inserted = (await queryRunner.query( @@ -137,10 +122,9 @@ describeDb('select: false runtime behaviour (real Postgres)', () => { )) as { id: number }[]; const id = inserted[0].id; - const rows = (await queryRunner.query( - `SELECT "hidden" FROM "select_false_probe" WHERE "id" = $1`, - [id], - )) as { hidden: string }[]; + const rows = (await queryRunner.query(`SELECT "hidden" FROM "select_false_probe" WHERE "id" = $1`, [id])) as { + hidden: string; + }[]; expect(rows).toHaveLength(1); expect(rows[0].hidden).toBe('hidden-secret'); From 175ee1f3485fec73f4009bd0992c469dc166916a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:00:44 +0200 Subject: [PATCH 3/9] test(auth): cover getSignature and state the guarantee precisely Both comments overclaimed: they said the value can never leave through an entity, while the same change proves otherwise - an explicit addSelect still loads it, which is exactly what getSignature() does. They now say what holds: select: false keeps the column out of every default selection, including entities returned through relations and eager ones, and explicit widening remains possible and should go through that one method. Adds unit coverage for getSignature() itself. The Postgres cases so far proved the ORM mechanism on a probe entity, so a removed addSelect or a mistyped alias in the real method would have gone unnoticed - and would have made it return undefined for every caller. The new cases pin the addSelect argument and the where clause, the returned value, and that a missing user yields undefined rather than throwing. Also switches the spec to an absolute import as CONTRIBUTING.md requires. --- .../signature-column-visibility.spec.ts | 2 +- .../models/user/tests/user.service.spec.ts | 36 +++++++++++++++++++ .../generic/user/models/user/user.entity.ts | 9 ++--- .../generic/user/models/user/user.service.ts | 6 ++-- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts index 232e34d0fc..2fde6cd0cd 100644 --- a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts @@ -1,5 +1,5 @@ import { Column, DataSource, Entity, getMetadataArgsStorage, PrimaryGeneratedColumn, QueryRunner } from 'typeorm'; -import { User } from '../user.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; const PG_URL = process.env.MIGRATION_TEST_PG; const describeDb = PG_URL ? describe : describe.skip; diff --git a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts index 5059a66db3..a48383750b 100644 --- a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts +++ b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts @@ -237,4 +237,40 @@ describe('UserService', () => { await expect(service.getOpenRefCreditEur()).resolves.toBe(0); }); }); + + describe('getSignature', () => { + function mockQuery(result: { id: number; signature?: string } | null) { + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(result), + }; + jest.spyOn(userRepo, 'createQueryBuilder').mockReturnValue(qb as any); + return qb; + } + + // Without addSelect, a select: false column is always undefined and login would break. + it('addSelects user.signature and filters by userId so the select: false column is loaded', async () => { + const userId = 42; + const qb = mockQuery({ id: userId, signature: 'sig-value' }); + + await service.getSignature(userId); + + expect(qb.addSelect).toHaveBeenCalledWith('user.signature'); + expect(qb.where).toHaveBeenCalledWith('user.id = :userId', { userId }); + }); + + it('returns the signature when getOne finds a user', async () => { + mockQuery({ id: 7, signature: 'the-stored-signature' }); + + await expect(service.getSignature(7)).resolves.toBe('the-stored-signature'); + }); + + it('returns undefined when getOne finds no user', async () => { + mockQuery(null); + + await expect(service.getSignature(99)).resolves.toBeUndefined(); + }); + }); }); diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index abdc15fcbb..2b81ab79d6 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -30,10 +30,11 @@ export class User extends IEntity { // Login credential: for DeFiChain and custodial Lightning the stored value IS the secret that // authenticates a sign-in (see AuthService.verifySignature). `select: false` keeps it out of every - // query by default, so it can never travel out through an entity returned by a controller - several - // admin endpoints return entities that reach this column through relations, and `Transaction.user` - // is eager, so it arrives even where no relation was requested. Read it only through - // UserService.getSignature(), never by widening a query. + // default selection, including entities returned through relations - several admin endpoints return + // entities that reach this column via relations, and `Transaction.user` is eager, so it would arrive + // even where no relation was requested. An explicit `addSelect` can still load it (that is what + // UserService.getSignature() does). Read it only through UserService.getSignature(), never by + // widening an existing query. @Column({ type: 'text', nullable: true, select: false }) signature?: string; diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 16fcba628f..5dd1cf4333 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -97,9 +97,9 @@ export class UserService { return this.userRepo.findOne({ where: { address }, relations }); } - // Deliberately the only read path for the login credential in `user.signature`: the column is - // `select: false`, so it never leaves through an entity. Keep this narrow - do not add the column - // to any other query. + // Deliberately the only read path for the login credential in `user.signature`. The column is + // `select: false` by default; this method is the only place that reloads it via explicit + // `addSelect`. Keep this query narrow - do not add the column to any other query. async getSignature(userId: number): Promise { const result = await this.userRepo .createQueryBuilder('user') From f9192d9de7a40233798929dc0322fe9a0a05aeae Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:14:52 +0200 Subject: [PATCH 4/9] style(auth): order the spec imports internal-first Cosmetic only; both orderings occur in this repo and no lint rule governs it. --- .../models/user/__tests__/signature-column-visibility.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts index 2fde6cd0cd..1cbbbf86f2 100644 --- a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts @@ -1,5 +1,5 @@ -import { Column, DataSource, Entity, getMetadataArgsStorage, PrimaryGeneratedColumn, QueryRunner } from 'typeorm'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { Column, DataSource, Entity, getMetadataArgsStorage, PrimaryGeneratedColumn, QueryRunner } from 'typeorm'; const PG_URL = process.env.MIGRATION_TEST_PG; const describeDb = PG_URL ? describe : describe.skip; From 1486972c51704d8dbc6e35d579de00dbea7de0fb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:31:30 +0200 Subject: [PATCH 5/9] test(auth): assert the resulting selection, not the addSelect call The previous assertion only checked that addSelect had been called with user.signature. That misses a real regression: select() replaces prior selections in TypeORM, so reordering the chain to addSelect(...).select(...) would drop the column, make getSignature() return undefined for everyone, and break the DeFiChain and custodial Lightning sign-ins - while leaving all three tests green. The query-builder mock now models that replacement semantics: select() resets the tracked selection, addSelect() appends. The test asserts the resulting selection contains the column. Verified by mutation: reordering the chain in getSignature() turns exactly this test red. --- .../models/user/tests/user.service.spec.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts index a48383750b..73dd2a3eb2 100644 --- a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts +++ b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts @@ -239,10 +239,20 @@ describe('UserService', () => { }); describe('getSignature', () => { + // Mirrors TypeORM SelectQueryBuilder: select() replaces the selection, addSelect() appends. function mockQuery(result: { id: number; signature?: string } | null) { + const selected: string[] = []; const qb = { - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), + selected, + select: jest.fn((expr: string) => { + selected.length = 0; + selected.push(expr); + return qb; + }), + addSelect: jest.fn((expr: string) => { + selected.push(expr); + return qb; + }), where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(result), }; @@ -250,14 +260,16 @@ describe('UserService', () => { return qb; } - // Without addSelect, a select: false column is always undefined and login would break. - it('addSelects user.signature and filters by userId so the select: false column is loaded', async () => { + // Assert the resulting selection, not merely that addSelect was called: a later select() + // would wipe user.signature (TypeORM replaces prior selections), which toHaveBeenCalledWith + // on addSelect would not catch. + it('includes user.signature in the resulting selection and filters by userId', async () => { const userId = 42; const qb = mockQuery({ id: userId, signature: 'sig-value' }); await service.getSignature(userId); - expect(qb.addSelect).toHaveBeenCalledWith('user.signature'); + expect(qb.selected).toContain('user.signature'); expect(qb.where).toHaveBeenCalledWith('user.id = :userId', { userId }); }); From 983eda556de2a152cf8a802e59beb7ef06589ec2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:19:42 +0200 Subject: [PATCH 6/9] fix(auth): strip the login signature for callers below admin Replaces the earlier approach. Marking the column select: false kept it out of every response, admins included - but admins are meant to load it normally, so the guarantee has to be role-dependent rather than absolute. The column is loaded as before. A globally registered interceptor removes it from responses whose caller does not satisfy the ADMIN requirement, reusing the hierarchy helper from role.guard.ts so ADMIN and SUPER_ADMIN pass untouched. Solving it centrally matters because the column reaches responses through relations across many admin endpoints, and Transaction.user is eager, so it arrives where no relation was requested - per-endpoint filtering would be incomplete and would be forgotten on the next endpoint. Filtering keys off the entity type, never the property name: other entities carry a field called signature and must not be touched. That property is pinned by its own test and verified by mutation - switching the check to the field name turns exactly that test red. The walk is cycle-safe via a WeakSet, since the entity graph is bidirectional, and a missing request.user counts as non-admin. getSignature() and its call site are gone again; doSignIn reads user.signature as it did before. --- src/app.module.ts | 11 +- .../signature-visibility.interceptor.spec.ts | 102 ++++++++++++++ .../auth/signature-visibility.interceptor.ts | 58 ++++++++ .../generic/user/models/auth/auth.service.ts | 6 +- .../signature-column-visibility.spec.ts | 132 ------------------ .../models/user/tests/user.service.spec.ts | 48 ------- .../generic/user/models/user/user.entity.ts | 11 +- .../generic/user/models/user/user.service.ts | 14 -- 8 files changed, 173 insertions(+), 209 deletions(-) create mode 100644 src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts create mode 100644 src/shared/auth/signature-visibility.interceptor.ts delete mode 100644 src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts diff --git a/src/app.module.ts b/src/app.module.ts index e762e5f825..bb0dad5ba0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { GetConfig } from './config/config'; import { IntegrationModule } from './integration/integration.module'; +import { SignatureVisibilityInterceptor } from './shared/auth/signature-visibility.interceptor'; import { TfaEnforcementInterceptor } from './shared/auth/tfa-enforcement.interceptor'; import { SharedModule } from './shared/shared.module'; import { SubdomainsModule } from './subdomains/subdomains.module'; @@ -11,9 +12,13 @@ import { SubdomainsModule } from './subdomains/subdomains.module'; @Module({ imports: [TypeOrmModule.forRoot(GetConfig().database), SharedModule, IntegrationModule, SubdomainsModule], controllers: [AppController], - // Global backstop enforcing STRICT app-2FA on every route for a mail-origin staff session (tfaRequired). - // Only needs ModuleRef + Reflector (both globally available), so no extra module imports are required. - providers: [{ provide: APP_INTERCEPTOR, useClass: TfaEnforcementInterceptor }], + providers: [ + // Global backstop enforcing STRICT app-2FA on every route for a mail-origin staff session (tfaRequired). + // Only needs ModuleRef + Reflector (both globally available), so no extra module imports are required. + { provide: APP_INTERCEPTOR, useClass: TfaEnforcementInterceptor }, + // Strips User.signature from every HTTP response whose caller is not ADMIN (or SUPER_ADMIN). + { provide: APP_INTERCEPTOR, useClass: SignatureVisibilityInterceptor }, + ], exports: [], }) export class AppModule {} diff --git a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts new file mode 100644 index 0000000000..858ec5f4c3 --- /dev/null +++ b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts @@ -0,0 +1,102 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { firstValueFrom, Observable, of } from 'rxjs'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { SignatureVisibilityInterceptor } from '../signature-visibility.interceptor'; + +describe('SignatureVisibilityInterceptor', () => { + let interceptor: SignatureVisibilityInterceptor; + let next: CallHandler; + let handle: jest.Mock; + + const context = (request: unknown): ExecutionContext => + createMock({ switchToHttp: () => ({ getRequest: () => request }) as never }); + + // The interceptor is typed against `unknown` payloads, so each case states the shape it put in. + const run = async (request: unknown): Promise => + firstValueFrom((await interceptor.intercept(context(request), next)) as Observable); + + beforeEach(() => { + handle = jest.fn(); + next = { handle } as CallHandler; + interceptor = new SignatureVisibilityInterceptor(); + }); + + it('passes a User entity through unchanged when the caller role is ADMIN (fast path)', async () => { + const user = Object.assign(new User(), { id: 1, signature: 'secret-sig', address: 'addr-1' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.ADMIN } }); + + expect(handle).toHaveBeenCalled(); + expect(result).toBe(user); + expect(result.signature).toBe('secret-sig'); + }); + + it('passes a User entity through unchanged when the caller role is SUPER_ADMIN', async () => { + const user = Object.assign(new User(), { id: 2, signature: 'secret-sig', address: 'addr-2' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.SUPER_ADMIN } }); + + expect(handle).toHaveBeenCalled(); + expect(result).toBe(user); + expect(result.signature).toBe('secret-sig'); + }); + + it('strips signature from a User entity when the caller role is SUPPORT, keeping other properties', async () => { + const user = Object.assign(new User(), { id: 3, signature: 'secret-sig', address: 'addr-3' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.SUPPORT } }); + + expect(result.signature).toBeUndefined(); + expect(result.address).toBe('addr-3'); + }); + + it('strips signature from a User entity when there is no request.user (unauthenticated)', async () => { + const user = Object.assign(new User(), { id: 4, signature: 'secret-sig', address: 'addr-4' }); + handle.mockReturnValue(of(user)); + + const result = await run({}); + + expect(result.signature).toBeUndefined(); + expect(result.address).toBe('addr-4'); + }); + + it('recursively strips signature from nested User entities (userData.users[])', async () => { + const userA = Object.assign(new User(), { id: 10, signature: 'sig-a' }); + const userB = Object.assign(new User(), { id: 11, signature: 'sig-b' }); + const payload = { userData: { users: [userA, userB] } }; + handle.mockReturnValue(of(payload)); + + const result = await run<{ userData: { users: User[] } }>({ user: { role: UserRole.SUPPORT } }); + + expect(result.userData.users[0].signature).toBeUndefined(); + expect(result.userData.users[1].signature).toBeUndefined(); + }); + + it('leaves a non-User object with its own signature property untouched (must not filter by field name)', async () => { + // Critical case: other entities (e.g. RealUnit registrations) also carry a `signature` field and + // must not be stripped — only `instanceof User` may trigger removal. + const other = { id: 1, signature: 'not-a-login-credential' }; + handle.mockReturnValue(of(other)); + + const result = await run<{ signature: string }>({ user: { role: UserRole.SUPPORT } }); + + expect(result.signature).toBe('not-a-login-credential'); + }); + + it('does not loop infinitely on a cyclic object graph', async () => { + const a: any = {}; + const b: any = { ref: a }; + a.ref = b; + handle.mockReturnValue(of(a)); + + const result = await run<{ ref: unknown }>({ user: { role: UserRole.SUPPORT } }); + + expect(result).toBe(a); + expect(result.ref).toBe(b); + }); +}); diff --git a/src/shared/auth/signature-visibility.interceptor.ts b/src/shared/auth/signature-visibility.interceptor.ts new file mode 100644 index 0000000000..e74a1a8be1 --- /dev/null +++ b/src/shared/auth/signature-visibility.interceptor.ts @@ -0,0 +1,58 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; + +// Global strip of the login credential on `User.signature` for non-admin HTTP responses. +// +// At least 13 endpoints surface this column today through relations; `Transaction.user` is eager and +// carries it even when no user relation was requested. Filtering per endpoint would be incomplete and +// would be forgotten on every new admin read path — this interceptor closes the leak in ONE place. +// +// Filtering is by entity type (`instanceof User`), never by property name: other entities in the repo +// also carry a field named `signature` (e.g. RealUnit registrations) and must not be stripped. +// Missing `request.user` is treated as non-admin (fail-closed), not as a special pass-through. +// A WeakSet tracks visited objects so cyclic entity graphs (bidirectional relations) do not loop. +// +// Registered as an APP_INTERCEPTOR next to TfaEnforcementInterceptor. +@Injectable() +export class SignatureVisibilityInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const role: UserRole | undefined = request.user?.role; + + // FAST PATH: ADMIN / SUPER_ADMIN traffic keeps the full response; no map, no walk. + // This is the bulk of admin-endpoint traffic and must stay a single role check. + if (hasRoleAccess(UserRole.ADMIN, role)) return next.handle(); + + return next.handle().pipe(map((data) => this.stripSignatures(data))); + } + + private stripSignatures(value: unknown, seen: WeakSet = new WeakSet()): unknown { + if (typeof value !== 'object' || value === null) return value; + if (value instanceof Date) return value; + if (value instanceof Buffer) return value; + if (seen.has(value as object)) return value; + + seen.add(value as object); + + if (Array.isArray(value)) { + for (const element of value) { + this.stripSignatures(element, seen); + } + return value; + } + + if (value instanceof User) { + delete (value as { signature?: string }).signature; + } + + for (const key of Object.keys(value as object)) { + this.stripSignatures((value as Record)[key], seen); + } + + return value; + } +} diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 45d9851d8a..258aa075b0 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -243,12 +243,8 @@ export class AuthService { private async doSignIn(user: User, dto: SignInDto & { wallet?: string }, userIp: string, isCustodial: boolean) { if (!user.custodyProvider || user.custodyProvider.masterKey !== dto.signature) { - // The column is `select: false` (it is a login credential), so the loaded entity does not carry - // it - fetch it explicitly for the comparison paths in verifySignature. - const dbSignature = await this.userService.getSignature(user.id); - if ( - !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, dbSignature, dto.blockchain)) + !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, user.signature, dto.blockchain)) ) { throw new UnauthorizedException('Invalid credentials'); } diff --git a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts b/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts deleted file mode 100644 index 1cbbbf86f2..0000000000 --- a/src/subdomains/generic/user/models/user/__tests__/signature-column-visibility.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { User } from 'src/subdomains/generic/user/models/user/user.entity'; -import { Column, DataSource, Entity, getMetadataArgsStorage, PrimaryGeneratedColumn, QueryRunner } from 'typeorm'; - -const PG_URL = process.env.MIGRATION_TEST_PG; -const describeDb = PG_URL ? describe : describe.skip; -const SCHEMA = 'signature_column_visibility_spec'; - -describe('User signature column metadata', () => { - it('marks signature with select: false so TypeORM excludes it from default queries', () => { - const column = getMetadataArgsStorage().columns.find((c) => c.target === User && c.propertyName === 'signature'); - - expect(column).toBeDefined(); - expect(column.options.select).toBe(false); - }); - - it('does not mark address with select: false (control for metadata reading)', () => { - const column = getMetadataArgsStorage().columns.find((c) => c.target === User && c.propertyName === 'address'); - - expect(column).toBeDefined(); - expect(column.options.select).toBeUndefined(); - }); -}); - -// Minimal probe entity: `hidden` mirrors User.signature column options -// (`type: 'text', nullable: true, select: false`) so this block exercises the -// ORM mechanism UserService.getSignature() relies on — not the User entity itself -// (too many relations for an isolated DataSource). -@Entity({ name: 'select_false_probe' }) -class SelectFalseProbe { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'text' }) - visible: string; - - @Column({ type: 'text', nullable: true, select: false }) - hidden?: string; -} - -describeDb('select: false runtime behaviour (real Postgres)', () => { - let dataSource: DataSource; - let queryRunner: QueryRunner; - - beforeAll(async () => { - dataSource = new DataSource({ - type: 'postgres', - url: PG_URL, - entities: [SelectFalseProbe], - schema: SCHEMA, - synchronize: false, - }); - await dataSource.initialize(); - }); - - beforeEach(async () => { - queryRunner = dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); - await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); - await queryRunner.query(`SET search_path TO "${SCHEMA}"`); - - await queryRunner.query(` - CREATE TABLE "select_false_probe" ( - "id" SERIAL PRIMARY KEY, - "visible" text NOT NULL, - "hidden" text - ) - `); - }); - - afterEach(async () => { - if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); - await queryRunner.query(`SET search_path TO public`); - await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); - await queryRunner.release(); - }); - - afterAll(async () => { - if (dataSource?.isInitialized) await dataSource.destroy(); - }); - - it('findOne omits a select: false column from the loaded entity', async () => { - const inserted = (await queryRunner.query( - `INSERT INTO "select_false_probe" ("visible", "hidden") - VALUES ('visible-value', 'hidden-secret') - RETURNING "id"`, - )) as { id: number }[]; - const id = inserted[0].id; - - const row = await dataSource.getRepository(SelectFalseProbe).findOne({ where: { id } }); - - expect(row).toBeDefined(); - expect(row.visible).toBe('visible-value'); - expect(row.hidden).toBeUndefined(); - }); - - it('addSelect returns a select: false column ' + '(the pattern UserService.getSignature() uses)', async () => { - const inserted = (await queryRunner.query( - `INSERT INTO "select_false_probe" ("visible", "hidden") - VALUES ('visible-value', 'hidden-secret') - RETURNING "id"`, - )) as { id: number }[]; - const id = inserted[0].id; - - const row = await dataSource - .getRepository(SelectFalseProbe) - .createQueryBuilder('probe') - .select('probe.id') - .addSelect('probe.hidden') - .where('probe.id = :id', { id }) - .getOne(); - - expect(row).toBeDefined(); - expect(row.hidden).toBe('hidden-secret'); - }); - - it('raw SQL still sees the value (select: false is ORM-level, not a data migration)', async () => { - const inserted = (await queryRunner.query( - `INSERT INTO "select_false_probe" ("visible", "hidden") - VALUES ('visible-value', 'hidden-secret') - RETURNING "id"`, - )) as { id: number }[]; - const id = inserted[0].id; - - const rows = (await queryRunner.query(`SELECT "hidden" FROM "select_false_probe" WHERE "id" = $1`, [id])) as { - hidden: string; - }[]; - - expect(rows).toHaveLength(1); - expect(rows[0].hidden).toBe('hidden-secret'); - }); -}); diff --git a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts index 73dd2a3eb2..5059a66db3 100644 --- a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts +++ b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts @@ -237,52 +237,4 @@ describe('UserService', () => { await expect(service.getOpenRefCreditEur()).resolves.toBe(0); }); }); - - describe('getSignature', () => { - // Mirrors TypeORM SelectQueryBuilder: select() replaces the selection, addSelect() appends. - function mockQuery(result: { id: number; signature?: string } | null) { - const selected: string[] = []; - const qb = { - selected, - select: jest.fn((expr: string) => { - selected.length = 0; - selected.push(expr); - return qb; - }), - addSelect: jest.fn((expr: string) => { - selected.push(expr); - return qb; - }), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(result), - }; - jest.spyOn(userRepo, 'createQueryBuilder').mockReturnValue(qb as any); - return qb; - } - - // Assert the resulting selection, not merely that addSelect was called: a later select() - // would wipe user.signature (TypeORM replaces prior selections), which toHaveBeenCalledWith - // on addSelect would not catch. - it('includes user.signature in the resulting selection and filters by userId', async () => { - const userId = 42; - const qb = mockQuery({ id: userId, signature: 'sig-value' }); - - await service.getSignature(userId); - - expect(qb.selected).toContain('user.signature'); - expect(qb.where).toHaveBeenCalledWith('user.id = :userId', { userId }); - }); - - it('returns the signature when getOne finds a user', async () => { - mockQuery({ id: 7, signature: 'the-stored-signature' }); - - await expect(service.getSignature(7)).resolves.toBe('the-stored-signature'); - }); - - it('returns undefined when getOne finds no user', async () => { - mockQuery(null); - - await expect(service.getSignature(99)).resolves.toBeUndefined(); - }); - }); }); diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 2b81ab79d6..269e0d3580 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -29,13 +29,10 @@ export class User extends IEntity { addressType?: UserAddressType; // Login credential: for DeFiChain and custodial Lightning the stored value IS the secret that - // authenticates a sign-in (see AuthService.verifySignature). `select: false` keeps it out of every - // default selection, including entities returned through relations - several admin endpoints return - // entities that reach this column via relations, and `Transaction.user` is eager, so it would arrive - // even where no relation was requested. An explicit `addSelect` can still load it (that is what - // UserService.getSignature() does). Read it only through UserService.getSignature(), never by - // widening an existing query. - @Column({ type: 'text', nullable: true, select: false }) + // authenticates a sign-in (see AuthService.verifySignature). Loaded normally like any other column; + // `SignatureVisibilityInterceptor` (registered as a global APP_INTERCEPTOR) strips it from every HTTP + // response whose caller does not satisfy the ADMIN role requirement. + @Column({ type: 'text', nullable: true }) signature?: string; @Column({ length: 256, nullable: true }) diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 5dd1cf4333..9200a9a2c7 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -97,20 +97,6 @@ export class UserService { return this.userRepo.findOne({ where: { address }, relations }); } - // Deliberately the only read path for the login credential in `user.signature`. The column is - // `select: false` by default; this method is the only place that reloads it via explicit - // `addSelect`. Keep this query narrow - do not add the column to any other query. - async getSignature(userId: number): Promise { - const result = await this.userRepo - .createQueryBuilder('user') - .select('user.id') - .addSelect('user.signature') - .where('user.id = :userId', { userId }) - .getOne(); - - return result?.signature; - } - async getUserByKey(key: string, value: any, onlyDefaultRelation = false): Promise { const query = this.userRepo .createQueryBuilder('user') From 5846cd36062b0accbcbb9830ea486799ba874862 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:47:28 +0200 Subject: [PATCH 7/9] test(auth): pin the global registration of the interceptor Every existing case constructs the interceptor directly, so none of them would notice if the APP_INTERCEPTOR entry were dropped from AppModule - the protection would be gone with all tests still green. This asserts the wiring itself. Verified by mutation: removing the provider entry turns exactly this test red. --- .../signature-visibility.interceptor.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts index 858ec5f4c3..fa0b146dc9 100644 --- a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts +++ b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts @@ -1,5 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { firstValueFrom, Observable, of } from 'rxjs'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; @@ -99,4 +100,15 @@ describe('SignatureVisibilityInterceptor', () => { expect(result).toBe(a); expect(result.ref).toBe(b); }); + + // The cases above exercise the interceptor directly, so none of them would notice if the global + // registration were dropped - the protection would be silently gone. This pins the wiring itself. + it('is registered globally as an APP_INTERCEPTOR', async () => { + const { AppModule } = await import('src/app.module'); + const providers = Reflect.getMetadata('providers', AppModule) as { provide?: symbol; useClass?: unknown }[]; + + expect(providers.some((p) => p.provide === APP_INTERCEPTOR && p.useClass === SignatureVisibilityInterceptor)).toBe( + true, + ); + }); }); From 4cbf00cf021389992f64db7ddb592186f65a08d3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:48:57 +0200 Subject: [PATCH 8/9] fix(auth): type the provider probe against the real token type APP_INTERCEPTOR is a string token, not a symbol, so the previous annotation made the comparison provably false and tsc rejected it. The test passed regardless, because Jest transpiles without type checking - the type-check gate caught it. --- .../auth/__tests__/signature-visibility.interceptor.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts index fa0b146dc9..652bd10252 100644 --- a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts +++ b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts @@ -105,7 +105,7 @@ describe('SignatureVisibilityInterceptor', () => { // registration were dropped - the protection would be silently gone. This pins the wiring itself. it('is registered globally as an APP_INTERCEPTOR', async () => { const { AppModule } = await import('src/app.module'); - const providers = Reflect.getMetadata('providers', AppModule) as { provide?: symbol; useClass?: unknown }[]; + const providers = Reflect.getMetadata('providers', AppModule) as { provide?: unknown; useClass?: unknown }[]; expect(providers.some((p) => p.provide === APP_INTERCEPTOR && p.useClass === SignatureVisibilityInterceptor)).toBe( true, From ccf649f04407a3acd6177f15f33deac53120d594 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:05:50 +0200 Subject: [PATCH 9/9] test(auth): type the cycle fixture and use the module-metadata constant Replaces the two any-typed cycle fixtures with a self-referencing interface, and reads the provider list through MODULE_METADATA.PROVIDERS instead of the raw string - the pattern accounting.module.spec.ts already uses. A hardcoded key would break silently if Nest renamed it, while the app stayed correct. --- .../signature-visibility.interceptor.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts index 652bd10252..edb09ae9e4 100644 --- a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts +++ b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts @@ -1,11 +1,17 @@ import { createMock } from '@golevelup/ts-jest'; import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { MODULE_METADATA } from '@nestjs/common/constants'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { firstValueFrom, Observable, of } from 'rxjs'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { SignatureVisibilityInterceptor } from '../signature-visibility.interceptor'; +// Self-referencing shape for the cycle case - the interceptor must terminate on it. +interface Cyclic { + ref?: Cyclic; +} + describe('SignatureVisibilityInterceptor', () => { let interceptor: SignatureVisibilityInterceptor; let next: CallHandler; @@ -90,12 +96,12 @@ describe('SignatureVisibilityInterceptor', () => { }); it('does not loop infinitely on a cyclic object graph', async () => { - const a: any = {}; - const b: any = { ref: a }; + const a: Cyclic = {}; + const b: Cyclic = { ref: a }; a.ref = b; handle.mockReturnValue(of(a)); - const result = await run<{ ref: unknown }>({ user: { role: UserRole.SUPPORT } }); + const result = await run({ user: { role: UserRole.SUPPORT } }); expect(result).toBe(a); expect(result.ref).toBe(b); @@ -105,7 +111,10 @@ describe('SignatureVisibilityInterceptor', () => { // registration were dropped - the protection would be silently gone. This pins the wiring itself. it('is registered globally as an APP_INTERCEPTOR', async () => { const { AppModule } = await import('src/app.module'); - const providers = Reflect.getMetadata('providers', AppModule) as { provide?: unknown; useClass?: unknown }[]; + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) as { + provide?: unknown; + useClass?: unknown; + }[]; expect(providers.some((p) => p.provide === APP_INTERCEPTOR && p.useClass === SignatureVisibilityInterceptor)).toBe( true,