From 811457791ebe3dd4b4b3c7719cde1a3aa928e79e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:15:53 +0200 Subject: [PATCH 1/2] e0e9ee06 - fix(auth): scope the login sign message per environment (#4482) * fix(auth): scope the login sign message per environment A wallet signature proves ownership of the exact text that was signed. That text was identical in every environment, so a signature stored in a lower environment verified on PRD as well. Non-PRD environments now prefix the sign message with their environment marker, which changes the signed payload and therefore the resulting signature. PRD keeps the historical text byte-for-byte, so existing PRD signatures stay valid. An unset or unknown ENVIRONMENT is deliberately treated as non-PRD: it must never accidentally yield a PRD-valid signature. Covered by two specs: one pinning the PRD text byte-for-byte and the fail-closed behaviour, one proving cryptographically that a signature made over the PRD text does not verify under the DEV text and vice versa. * feat(migration): rotate the DEV wallet signatures with an audited fingerprint The stored user.signature values on DEV were produced with the previous environment-independent sign message and would therefore still verify on PRD. This clears them. CONTRIBUTING.md requires an overwritten value to stay recoverable from the database. That cannot be satisfied literally here, and the conflict is inherent: the values are login credentials, so retaining them recoverably would preserve exactly what this rotation removes. Instead the migration writes an audit row to log before the update, holding the affected user id and an md5 fingerprint of the previous signature rather than its plaintext, and couples the update fail-closed to that insert via EXISTS (SELECT 1 FROM "audit") - following the precedent in 1784600000011-FixFiatOutputValutaDateSerials.js. A known candidate value can be checked against the fingerprint; the credential itself cannot be recovered. md5 acts purely as an audit fingerprint here. down() deliberately restores nothing: the prior value is not reconstructible by design. * test(auth): verify the signature rotation against a real database The migration spec only matched substrings against a mocked query runner, so an invalid CTE or a broken audit coupling would have passed unnoticed. Adds a MIGRATION_TEST_PG-gated Postgres block, following the pattern already used by most migration specs in this repo. Four scenarios: the rotation writes exactly one audit row whose fingerprints match independently computed md5 values; an empty candidate set writes no audit row and changes nothing; a failing audit insert (log table dropped) rejects and leaves the signature intact, which is the fail-closed property; and the environment gate holds against a real database. Also switches both sign-message specs to absolute imports as required by CONTRIBUTING.md, and states the reason for the require-import lint exception. * test(auth): isolate the audit coupling and pin the fingerprint-only guarantee The previous commit claimed its dropped-log-table test proved the fail-closed property. It did not: because the audit insert and the update are one statement, Postgres rolls the statement back on any insert error, so that test passes even without the EXISTS coupling. Verified by mutation - removing the coupling left it green. Its title now says what it actually shows, statement atomicity. The coupling is now isolated by a test where the insert SUCCEEDS but yields no row: a BEFORE INSERT trigger on log discards it without raising. up() then completes, no audit row exists, and the signature must stay intact. Removing the coupling turns exactly this test red. Also pins the property the audit exception rests on - no plaintext signature reaches the log, asserted negatively against both fixtures and against a before field - narrows the env-scope titles to the text difference they actually assert, since the cryptographic proof lives in the replay spec, and covers an unknown ENVIRONMENT value. * test(auth): make the down() assertion non-tautological The mock was created but never passed to down(), which takes no query runner, so the assertion observed an object the call could not reach. It would have stayed green through any regression. The unit test now asserts the actual property - down() takes zero parameters and resolves - and the behavioural guarantee is pinned against a live database instead: after up() followed by down(), the signature stays NULL and the single audit row remains. Passing the mock in would have meant giving the migration a parameter it does not need, changing production code to satisfy a test. * test(auth): assert the environment gate for every non-dev value Two titles claimed coverage for any non-dev ENVIRONMENT while their bodies set only 'prd'. A regression that executed on loc, on an unset value or on an unknown one would have left both green. Both are now table-driven over prd, loc, staging and unset - at the unit level and against a live database - so the fail-closed guarantee of the strict !== 'dev' check is pinned across the whole value space instead of being inferred from a single case. --- .../1785500000000-ClearDevUserSignatures.js | 89 +++++ src/config/config.ts | 11 + .../sign-message-cross-env-replay.spec.ts | 54 +++ .../__tests__/sign-message-env-scope.spec.ts | 62 ++++ ...lear-dev-user-signatures.migration.spec.ts | 351 ++++++++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 migration/1785500000000-ClearDevUserSignatures.js create mode 100644 src/subdomains/generic/user/models/auth/__tests__/sign-message-cross-env-replay.spec.ts create mode 100644 src/subdomains/generic/user/models/auth/__tests__/sign-message-env-scope.spec.ts create mode 100644 src/subdomains/generic/user/models/user/__tests__/clear-dev-user-signatures.migration.spec.ts diff --git a/migration/1785500000000-ClearDevUserSignatures.js b/migration/1785500000000-ClearDevUserSignatures.js new file mode 100644 index 0000000000..b26ea77318 --- /dev/null +++ b/migration/1785500000000-ClearDevUserSignatures.js @@ -0,0 +1,89 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * DEV-ONLY credential rotation: clears stored user wallet signatures that were produced with the + * old environment-independent sign message. + * + * Before the env-scoped sign-message fix, every environment signed the same historical text. A + * signature stored on DEV was therefore also valid on PRD — a DEV database read would yield working + * PRD login credentials. After the fix, non-PRD environments prefix the sign message, so new DEV + * signatures no longer verify on PRD. Existing DEV rows still hold the old (PRD-valid) signatures + * and must be discarded. + * + * Guarded to `ENVIRONMENT === 'dev'` so loc/prd/CI are no-ops. Returning early still records the + * migration as executed, which is the intended no-op outside DEV. + * + * up(): + * 1. dev guard (no-op elsewhere) + * 2. audit-then-null via data-modifying CTEs: lock non-null signatures, insert an audit log row + * with md5 fingerprints (not plaintext credentials), then null signatures only if the audit + * insert succeeded (fail-closed) + * + * down() deliberately does NOT restore values: signatures are login credentials and only their + * md5 fingerprint is retained in the audit log, so the prior value is not reconstructible by + * design. Always a no-op in every environment (not a guaranteed inverse). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class ClearDevUserSignatures1785500000000 { + name = 'ClearDevUserSignatures1785500000000'; + + /** + * Credential rotation: discard all DEV-stored signatures that were produced with the old, + * environment-independent sign message and would (before this fix) have authenticated on PRD. + * + * Audit trail stores md5("signature") fingerprints rather than plaintext: a candidate value can + * still be checked against the fingerprint during an investigation, without keeping the login + * credential in the database. md5 is used here solely as an audit fingerprint — not a security + * primitive (no password hashing, no salt; pure evidentiary purpose). + * + * EXISTS (SELECT 1 FROM "audit") fail-closes the UPDATE to the audit INSERT: if the insert fails, + * no row is changed (CONTRIBUTING.md "Auditable mutations — no destructive overwrites (CRITICAL)", + * before→after audit before the update). + * + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + if (process.env.ENVIRONMENT !== 'dev') return; + + await queryRunner.query(` +WITH "affected" AS ( + SELECT "id", md5("signature") AS "fingerprint" + FROM "user" + WHERE "signature" IS NOT NULL + FOR UPDATE +), +"audit" AS ( + INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message") + SELECT now(), now(), 'User', 'DevSignatureRotation', 'Info', + json_agg(json_build_object( + 'id', "id", + 'beforeFingerprint', "fingerprint", + 'after', null + ))::text + FROM "affected" + HAVING count(*) > 0 + RETURNING 1 +) +UPDATE "user" u +SET "signature" = NULL +FROM "affected" a +WHERE u."id" = a."id" AND EXISTS (SELECT 1 FROM "audit"); +`); + } + + /** + * Deliberately does not restore signature values: they are login credentials and only their + * fingerprint remains in the audit log, so the prior value is not reconstructible by design. + * Always a no-op in every environment — no env gate needed. + */ + async down() { + // Deliberately not restoring signature values: they are login credentials and only their + // md5 fingerprint is retained in the audit log, so the prior value is not reconstructible by + // design. Not a guaranteed inverse; no-op in every environment. + } +}; diff --git a/src/config/config.ts b/src/config/config.ts index 39ee28e3ba..a297cdffc3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -290,6 +290,15 @@ export class Configuration { resolvers: [{ resolve: () => this.i18n.fallbackLanguage }], }; + // A signature only proves ownership of the exact text that was signed. All environments share the + // same wallets and the same message, so a signature created on a lower environment authenticates on + // PRD as well — a DEV database read would yield working PRD credentials. PRD therefore keeps the + // historical text byte-for-byte (existing PRD signatures stay valid), while every other environment + // signs a distinct text and thus produces a signature PRD cannot verify. An unset or unknown + // ENVIRONMENT is deliberately treated as non-PRD: it must never accidentally yield a PRD-valid + // signature. + signMessagePrefix = this.environment === Environment.PRD ? '' : `[${this.environment}]_`; + auth = { jwt: { secret: process.env.JWT_SECRET, @@ -312,8 +321,10 @@ export class Configuration { // are never affected by this flag. tfaStaffEnforced: process.env.TFA_STAFF_ENFORCED !== 'false', signMessage: + this.signMessagePrefix + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_DeFiChain_address_and_are_in_possession_of_its_private_key._Your_ID:_', signMessageGeneral: + this.signMessagePrefix + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_', }; diff --git a/src/subdomains/generic/user/models/auth/__tests__/sign-message-cross-env-replay.spec.ts b/src/subdomains/generic/user/models/auth/__tests__/sign-message-cross-env-replay.spec.ts new file mode 100644 index 0000000000..62ffbe1de7 --- /dev/null +++ b/src/subdomains/generic/user/models/auth/__tests__/sign-message-cross-env-replay.spec.ts @@ -0,0 +1,54 @@ +import { ethers } from 'ethers'; +import { verifyMessage } from 'ethers/lib/utils'; +import { Configuration } from 'src/config/config'; + +// Test-only private key — never a real wallet; exists solely in this unit test. +const wallet = new ethers.Wallet('0x' + '01'.repeat(32)); + +describe("sign-message cross-env replay (a signature valid on one environment's text must not verify on another's)", () => { + const originalEnv = process.env.ENVIRONMENT; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); + + it('signMessageGeneral: PRD signature must not verify under the DEV message (and vice versa)', async () => { + process.env.ENVIRONMENT = 'prd'; + const prdConfig = new Configuration(); + const prdMessage = prdConfig.auth.signMessageGeneral + wallet.address; + const signature = await wallet.signMessage(prdMessage); + + expect(verifyMessage(prdMessage, signature).toLowerCase()).toBe(wallet.address.toLowerCase()); + + process.env.ENVIRONMENT = 'dev'; + const devConfig = new Configuration(); + const devMessage = devConfig.auth.signMessageGeneral + wallet.address; + + expect(verifyMessage(devMessage, signature).toLowerCase()).not.toBe(wallet.address.toLowerCase()); + + const devSignature = await wallet.signMessage(devMessage); + expect(verifyMessage(prdMessage, devSignature).toLowerCase()).not.toBe(wallet.address.toLowerCase()); + }); + + it('signMessage: PRD signature must not verify under the DEV message (and vice versa)', async () => { + process.env.ENVIRONMENT = 'prd'; + const prdConfig = new Configuration(); + const prdMessage = prdConfig.auth.signMessage + wallet.address; + const signature = await wallet.signMessage(prdMessage); + + expect(verifyMessage(prdMessage, signature).toLowerCase()).toBe(wallet.address.toLowerCase()); + + process.env.ENVIRONMENT = 'dev'; + const devConfig = new Configuration(); + const devMessage = devConfig.auth.signMessage + wallet.address; + + expect(verifyMessage(devMessage, signature).toLowerCase()).not.toBe(wallet.address.toLowerCase()); + + const devSignature = await wallet.signMessage(devMessage); + expect(verifyMessage(prdMessage, devSignature).toLowerCase()).not.toBe(wallet.address.toLowerCase()); + }); +}); diff --git a/src/subdomains/generic/user/models/auth/__tests__/sign-message-env-scope.spec.ts b/src/subdomains/generic/user/models/auth/__tests__/sign-message-env-scope.spec.ts new file mode 100644 index 0000000000..720a6c7090 --- /dev/null +++ b/src/subdomains/generic/user/models/auth/__tests__/sign-message-env-scope.spec.ts @@ -0,0 +1,62 @@ +import { Configuration } from 'src/config/config'; + +const HISTORICAL_SIGN_MESSAGE = + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_DeFiChain_address_and_are_in_possession_of_its_private_key._Your_ID:_'; +const HISTORICAL_SIGN_MESSAGE_GENERAL = + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_'; + +// Configured sign-message *text* per ENVIRONMENT only — no sign/verify here. +// Cryptographic cross-env replay proof lives in sign-message-cross-env-replay.spec.ts. +describe('sign-message env scope (configured message text differs by ENVIRONMENT)', () => { + const originalEnv = process.env.ENVIRONMENT; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); + + it('PRD keeps the historical sign message text byte-for-byte (no prefix)', () => { + process.env.ENVIRONMENT = 'prd'; + const config = new Configuration(); + + expect(config.auth.signMessage).toBe(HISTORICAL_SIGN_MESSAGE); + expect(config.auth.signMessageGeneral).toBe(HISTORICAL_SIGN_MESSAGE_GENERAL); + }); + + it('DEV uses a distinct sign message text prefixed with [dev]_ (not a crypto proof)', () => { + process.env.ENVIRONMENT = 'dev'; + const config = new Configuration(); + + expect(config.auth.signMessage).not.toBe(HISTORICAL_SIGN_MESSAGE); + expect(config.auth.signMessageGeneral).not.toBe(HISTORICAL_SIGN_MESSAGE_GENERAL); + expect(config.auth.signMessage.startsWith('[dev]_')).toBe(true); + expect(config.auth.signMessageGeneral.startsWith('[dev]_')).toBe(true); + }); + + it('LOC uses a sign message text distinct from the historical PRD text', () => { + process.env.ENVIRONMENT = 'loc'; + const config = new Configuration(); + + expect(config.auth.signMessage).not.toBe(HISTORICAL_SIGN_MESSAGE); + expect(config.auth.signMessageGeneral).not.toBe(HISTORICAL_SIGN_MESSAGE_GENERAL); + }); + + it('unset ENVIRONMENT uses a sign message text distinct from the historical PRD text', () => { + delete process.env.ENVIRONMENT; + const config = new Configuration(); + + expect(config.auth.signMessage).not.toBe(HISTORICAL_SIGN_MESSAGE); + expect(config.auth.signMessageGeneral).not.toBe(HISTORICAL_SIGN_MESSAGE_GENERAL); + }); + + it('an unknown ENVIRONMENT value uses a sign message text distinct from the historical PRD text', () => { + process.env.ENVIRONMENT = 'staging'; + const config = new Configuration(); + + expect(config.auth.signMessage).not.toBe(HISTORICAL_SIGN_MESSAGE); + expect(config.auth.signMessageGeneral).not.toBe(HISTORICAL_SIGN_MESSAGE_GENERAL); + }); +}); diff --git a/src/subdomains/generic/user/models/user/__tests__/clear-dev-user-signatures.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/clear-dev-user-signatures.migration.spec.ts new file mode 100644 index 0000000000..656992f819 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/clear-dev-user-signatures.migration.spec.ts @@ -0,0 +1,351 @@ +import { createHash } from 'crypto'; +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'clear_dev_user_signatures_spec'; + +let ClearDevUserSignatures: new () => { + up(queryRunner: QueryRunner): Promise; + down(): Promise; +}; + +describe('ClearDevUserSignatures migration (SQL content)', () => { + const originalEnv = process.env.ENVIRONMENT; + + beforeAll(() => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + ClearDevUserSignatures = require('../../../../../../../migration/1785500000000-ClearDevUserSignatures'); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); + + // Covers every non-dev value, not just 'prd': the gate is fail-closed by construction + // (`!== 'dev'`), and a regression that ran on loc, on an unset value or on an unknown one must + // fail here rather than slip through a single-value test. + it.each([['prd'], ['loc'], ['staging'], [undefined]])( + 'up() issues no queries when ENVIRONMENT is %s', + async (environment) => { + if (environment === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = environment; + } + const migration = new ClearDevUserSignatures(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query.mock.calls).toHaveLength(0); + }, + ); + + it('up() issues the audited rotation statement on dev (SQL content only, not executed)', async () => { + process.env.ENVIRONMENT = 'dev'; + const migration = new ClearDevUserSignatures(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.up(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(1); + for (const call of calls) { + expect(call).toHaveLength(1); + } + + const sql = calls[0][0]; + expect(sql).toContain('SET "signature" = NULL'); + expect(sql).toContain('"signature" IS NOT NULL'); + expect(sql).toContain('"user"'); + expect(sql).toContain('INSERT INTO "log"'); + expect(sql).toContain('md5("signature")'); + expect(sql).toContain('DevSignatureRotation'); + expect(sql).toContain('EXISTS (SELECT 1 FROM "audit")'); + }); + + // down() deliberately takes no query runner, because the rotation is irreversible and there is + // nothing to execute. Asserting against a mock would be tautological here - the mock could never + // be reached. The behavioural guarantee is pinned against a live database in the Postgres block. + it('down() takes no query runner and resolves (the rotation is deliberately irreversible)', async () => { + const migration = new ClearDevUserSignatures(); + + expect(migration.down).toHaveLength(0); + await expect(migration.down()).resolves.toBeUndefined(); + }); +}); + +describeDb('ClearDevUserSignatures migration (real Postgres)', () => { + const originalEnv = process.env.ENVIRONMENT; + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + ClearDevUserSignatures = require('../../../../../../../migration/1785500000000-ClearDevUserSignatures'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + process.env.ENVIRONMENT = 'dev'; + + 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 "user" ( + "id" SERIAL PRIMARY KEY, + "address" text, + "signature" text + ) + `); + + await queryRunner.query(` + CREATE TABLE "log" ( + "id" SERIAL PRIMARY KEY, + "created" TIMESTAMP NOT NULL DEFAULT now(), + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "system" varchar(256) NOT NULL, + "subsystem" varchar(256) NOT NULL, + "severity" varchar(256) NOT NULL, + "message" text NOT NULL, + "category" varchar(256), + "valid" boolean + ) + `); + }); + + afterEach(async () => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + + 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('rotates non-null signatures and writes exactly one audit log row with md5 fingerprints', async () => { + process.env.ENVIRONMENT = 'dev'; + + const fp1 = createHash('md5').update('sig-plaintext-1').digest('hex'); + const fp2 = createHash('md5').update('sig-plaintext-2').digest('hex'); + + const inserted = (await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES + ('addr-1', 'sig-plaintext-1'), + ('addr-2', 'sig-plaintext-2'), + ('addr-3', NULL) + RETURNING "id", "address" + `)) as { id: number; address: string }[]; + + const idByAddress = new Map(inserted.map((row) => [row.address, row.id])); + const id1 = idByAddress.get('addr-1'); + const id2 = idByAddress.get('addr-2'); + const id3 = idByAddress.get('addr-3'); + + const migration = new ClearDevUserSignatures(); + await migration.up(queryRunner); + + const users = (await queryRunner.query(`SELECT "id", "signature" FROM "user" ORDER BY "id"`)) as { + id: number; + signature: string | null; + }[]; + + expect(users).toHaveLength(3); + expect(users.find((u) => u.id === id1)?.signature).toBeNull(); + expect(users.find((u) => u.id === id2)?.signature).toBeNull(); + expect(users.find((u) => u.id === id3)?.signature).toBeNull(); + + const logs = (await queryRunner.query( + `SELECT * FROM "log" WHERE "system" = 'User' AND "subsystem" = 'DevSignatureRotation'`, + )) as { message: string }[]; + + expect(logs).toHaveLength(1); + + const entries = JSON.parse(logs[0].message) as { + id: number; + beforeFingerprint: string; + after: null; + }[]; + + expect(entries).toHaveLength(2); + + const entry1 = entries.find((e) => e.id === id1); + const entry2 = entries.find((e) => e.id === id2); + + expect(entry1).toBeDefined(); + expect(entry1?.beforeFingerprint).toBe(fp1); + expect(entry1?.after).toBeNull(); + + expect(entry2).toBeDefined(); + expect(entry2?.beforeFingerprint).toBe(fp2); + expect(entry2?.after).toBeNull(); + + // Audit log must never hold plaintext signatures — only md5 fingerprints. + expect(logs[0].message).not.toContain('sig-plaintext-1'); + expect(logs[0].message).not.toContain('sig-plaintext-2'); + for (const entry of entries) { + expect(entry).not.toHaveProperty('before'); + } + }); + + it('writes no audit row and changes nothing when there are no non-null signatures', async () => { + process.env.ENVIRONMENT = 'dev'; + + await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES ('addr-empty', NULL) + `); + + const migration = new ClearDevUserSignatures(); + await migration.up(queryRunner); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(0); + + const users = (await queryRunner.query(`SELECT "signature" FROM "user" WHERE "address" = 'addr-empty'`)) as { + signature: string | null; + }[]; + expect(users).toHaveLength(1); + expect(users[0].signature).toBeNull(); + }); + + // Proves statement atomicity only (failed insert rolls back the whole statement). + // Does not isolate the EXISTS coupling — see the suppress-trigger test below. + it('rejects and leaves the signature intact when the audit table is missing (statement atomicity)', async () => { + process.env.ENVIRONMENT = 'dev'; + + await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES ('addr-fail', 'sig-plaintext-3') + `); + + await queryRunner.query(`DROP TABLE "log"`); + + const migration = new ClearDevUserSignatures(); + await expect(migration.up(queryRunner)).rejects.toThrow(); + + // Failed statement may leave the connection unusable; read committed state via a fresh runner. + const verifyRunner = dataSource.createQueryRunner(); + await verifyRunner.connect(); + try { + await verifyRunner.query(`SET search_path TO "${SCHEMA}"`); + const users = (await verifyRunner.query(`SELECT "signature" FROM "user" WHERE "address" = 'addr-fail'`)) as { + signature: string | null; + }[]; + expect(users).toHaveLength(1); + expect(users[0].signature).toBe('sig-plaintext-3'); + } finally { + await verifyRunner.release(); + } + }); + + // The test above only proves PG statement atomicity (a failing insert rolls back UPDATE too), + // independent of whether the migration has EXISTS. This test isolates the structural coupling: + // a successful but rowless audit insert must not null signatures either. + it('does not null the signature when the audit insert yields no row (isolates the EXISTS coupling)', async () => { + process.env.ENVIRONMENT = 'dev'; + + await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES ('addr-suppressed', 'sig-plaintext-suppressed') + `); + + await queryRunner.query(` + CREATE FUNCTION suppress_log_insert() RETURNS trigger AS $fn$ + BEGIN + RETURN NULL; + END; + $fn$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE TRIGGER suppress_log_insert_trigger + BEFORE INSERT ON "log" + FOR EACH ROW + EXECUTE FUNCTION suppress_log_insert() + `); + + const migration = new ClearDevUserSignatures(); + await migration.up(queryRunner); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(0); + + const users = (await queryRunner.query(`SELECT "signature" FROM "user" WHERE "address" = 'addr-suppressed'`)) as { + signature: string | null; + }[]; + expect(users).toHaveLength(1); + expect(users[0].signature).toBe('sig-plaintext-suppressed'); + }); + + // Every non-dev value, not just 'prd' - see the unit-level counterpart above. + it.each([['prd'], ['loc'], ['staging'], [undefined]])( + 'does nothing on a real database when ENVIRONMENT is %s', + async (environment) => { + if (environment === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = environment; + } + + await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES ('addr-nondev', 'sig-plaintext-nondev') + `); + + const migration = new ClearDevUserSignatures(); + await migration.up(queryRunner); + + const users = (await queryRunner.query(`SELECT "signature" FROM "user" WHERE "address" = 'addr-nondev'`)) as { + signature: string | null; + }[]; + expect(users).toHaveLength(1); + expect(users[0].signature).toBe('sig-plaintext-nondev'); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(0); + }, + ); + + it('down() restores nothing against a live database (the rotation stays irreversible)', async () => { + process.env.ENVIRONMENT = 'dev'; + + await queryRunner.query(` + INSERT INTO "user" ("address", "signature") + VALUES ('addr-down', 'sig-plaintext-down') + `); + + const migration = new ClearDevUserSignatures(); + await migration.up(queryRunner); + await migration.down(); + + const users = (await queryRunner.query(`SELECT "signature" FROM "user" WHERE "address" = 'addr-down'`)) as { + signature: string | null; + }[]; + expect(users).toHaveLength(1); + expect(users[0].signature).toBeNull(); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(1); + }); +}); From 77a10620761ce2e05f74518f1effcb0009cd1683 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:21:32 +0200 Subject: [PATCH 2/2] d26bf998 - test(coverage): close the last uncovered branches and pin the completed files (#4483) * test(coverage): close the last uncovered branches on three files listKeys was never called without a prefix, the S3 WORM guard's non-Error fallbacks were never hit, and the scorechain screening entity's JSON getters were never read with a value set. Add the missing cases and pin all three files in the coverage ratchet. The unreachable `?? name` in the mock storage's getBlob is replaced by a slice: split always yields at least one element, so pop can never be undefined there and no test could ever cover that fallback. * test(coverage): pin four more completed files and refresh the numbers The gate run for the previous commit surfaced four files that already hold 100% on all four metrics but were never pinned; they arrived with recently merged PRs. Pin them and bring docs/coverage-gate.md to the measured state. Also records what the run made visible: part of the remaining branches are defensive fallbacks that cannot fire at runtime, so they are closed by removing the fallback, not by inventing a mock for them. * test(coverage): pin the last completed file and rebase the measurement Rebasing onto current develop moved the totals slightly and surfaced one more file already at 100%. Pin it and restate the numbers against the commit they were actually measured on. * docs(coverage): correct the Frick gate size and the importer counts The gate table still described the Frick gate as covering 7 files run by 7 specs; both the script and jest.frick.config.js have listed 10 for a while. The test-scaffolding note counted 60 and 28 importers, now 62 and 29 - all of them still specs, so the surrounding claim is unchanged. * docs(coverage): finish correcting the Frick gate size Two more places still said the Frick gate runs seven specs: the prose in the measurement section and the header comment of the ratchet config. A sweep over the three files confirms these were the last ones. --- docs/coverage-gate.md | 58 +++++++------- jest.coverage-gate.config.js | 12 ++- .../__tests__/mock-storage.service.spec.ts | 8 ++ .../__tests__/s3-storage.service.spec.ts | 14 ++++ .../storage/mock-storage.service.ts | 2 +- .../scorechain-screening.entity.spec.ts | 75 +++++++++++++++++++ 6 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 src/integration/scorechain/entities/__tests__/scorechain-screening.entity.spec.ts diff --git a/docs/coverage-gate.md b/docs/coverage-gate.md index a10c1f9316..2c8084c482 100644 --- a/docs/coverage-gate.md +++ b/docs/coverage-gate.md @@ -3,10 +3,10 @@ This repo runs two coverage gates in CI. They answer different questions, and neither replaces the other. -| Gate | Config | Scope | Question it answers | -| ---------------- | ------------------------------ | ---------------------------------------- | -------------------------------------------------------- | -| Frick gate | `jest.frick.config.js` | 7 Frick files, run by 7 Frick specs only | Do _these specs alone_ fully cover _these files_? | -| Coverage ratchet | `jest.coverage-gate.config.js` | 413 files, whole suite | Has coverage regressed anywhere it was already complete? | +| Gate | Config | Scope | Question it answers | +| ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- | +| Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? | +| Coverage ratchet | `jest.coverage-gate.config.js` | 421 files, whole suite | Has coverage regressed anywhere it was already complete? | ## What the ratchet is, and what it is not @@ -16,7 +16,7 @@ file, CI fails. It is a **regression gate**, not a statement about test quality: -- It does not claim the repo is well tested. Overall coverage is 59.45% of statements and 42.43% +- It does not claim the repo is well tested. Overall coverage is 59.57% of statements and 42.46% of branches; the pinned files are the subset that happens to be complete today. - It does not verify that a file's _own_ spec covers it. Under a whole-suite run, coverage may come from any spec. The Frick gate is the one that makes the stronger per-spec claim, which is @@ -25,8 +25,8 @@ It is a **regression gate**, not a statement about test quality: ratchet only protects files already on the list, and that list grows by hand (see "How the list grows"). That is the price of the threshold approach. -Of the 413 pinned files, **225 carry real logic** (they have functions and/or branches) and -**188 are purely declarative today** (NestJS modules, constant files with neither). The two groups +Of the 421 pinned files, **232 carry real logic** (they have functions and/or branches) and +**189 are purely declarative today** (NestJS modules, constant files with neither). The two groups are kept visibly separate in the config so the count is not mistaken for test depth. Pinning the declarative ones is deliberate and not vacuous. Istanbul reports a metric with a total @@ -35,7 +35,7 @@ and fails the threshold. Statements and lines are pinned as well, so even top-le code that no test reaches turns the gate red. Test scaffolding is excluded. `shared/utils/test.util.ts` and `shared/utils/test.shared.module.ts` -live outside a `__tests__` directory but are imported only by specs (60 and 28 importers, all +live outside a `__tests__` directory but are imported only by specs (62 and 29 importers, all `*.spec.ts`). They are filtered out of `collectCoverageFrom`, so an untested change to a test helper cannot fail a production gate. @@ -67,7 +67,7 @@ statement executed by a suite counts as executed no matter which worker ran it. cannot turn a covered file into an uncovered one, which is why the CI script does not serialise. The gate runs the whole suite under full compilation, unlike the sharded `test` job that splits -the suite three ways and the Frick gate that runs seven specs. Exact per-file numbers are what +the suite three ways and the Frick gate that runs ten specs. Exact per-file numbers are what that costs in run time. ## Where the gate runs @@ -150,9 +150,9 @@ warm caches, is a good deal slower than the 1.5 min it takes in CI. ## Current state -Measured on develop @ e6139b860. +Measured on develop @ 045e6f8d6 with this PR's tests applied. -The collection glob matches 1,656 files under `src/`. 1,605 of them contain instrumentable code +The collection glob matches 1,657 files under `src/`. 1,606 of them contain instrumentable code and appear in the report. The remaining 51 compile to no executable statements and therefore cannot be measured or pinned: 49 are type-only (interfaces, type aliases, response shapes), one consists entirely of commented-out code (`integration/exchange/services/p2b.service.ts`) and one @@ -161,11 +161,11 @@ deleting them would be a separate cleanup. | Class | Files | Meaning | | -------- | ----- | ----------------------------------------------- | -| Complete | 413 | Pinned by the ratchet | -| Partial | 1,062 | Some coverage, below 100 on at least one metric | -| None | 130 | No coverage at all | +| Complete | 421 | Pinned by the ratchet | +| Partial | 1,058 | Some coverage, below 100 on at least one metric | +| None | 127 | No coverage at all | -Totals: statements 59.45%, branches 42.43%, functions 34.09%, lines 59.81%. +Totals: statements 59.57%, branches 42.46%, functions 34.16%, lines 59.93%. Coverage is very unevenly distributed. `subdomains/supporting/payout` has 69 of 102 files complete; `subdomains/supporting/dex` has 6 of 170, `subdomains/supporting/payin` 6 of 102, and @@ -175,20 +175,28 @@ six under `subdomains/generic/admin` have no coverage at all. ## How the list grows Any PR may add files to `coverageThreshold` once they reach 100%. -`jest.coverage-gate.config.js` holds the 413 paths in two arrays, `PINNED_LOGIC` (logic-carrying +`jest.coverage-gate.config.js` holds the 421 paths in two arrays, `PINNED_LOGIC` (logic-carrying files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is generated. Adding a file means appending its path to the matching array, not writing out a `coverageThreshold` object entry by hand. -The intended next step is the set already within reach: **29 files sit at ≥90% on all four +The intended next step is the set already within reach: **26 files sit at ≥90% on all four metrics**, several of them one or two uncovered branches away. Examples: -| File | branches | functions | lines | statements | -| --------------------------------------------------------------------------- | -------- | --------- | ----- | ---------- | -| `src/subdomains/core/accounting/services/ledger-cutover.service.ts` | 98.55 | 100 | 99.33 | 99.1 | -| `src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts` | 96.29 | 100 | 100 | 99.54 | -| `src/subdomains/core/accounting/services/ledger-reconciliation.service.ts` | 95.52 | 100 | 99.41 | 99.48 | -| `src/integration/infrastructure/storage/s3-storage.service.ts` | 95 | 100 | 100 | 100 | +| File | branches | functions | lines | statements | +| ---------------------------------------------------------------------------- | -------- | --------- | ----- | ---------- | +| `src/subdomains/core/accounting/services/ledger-cutover.service.ts` | 98.55 | 100 | 99.33 | 99.1 | +| `src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts` | 96.29 | 100 | 100 | 99.54 | +| `src/subdomains/core/accounting/services/ledger-reconciliation.service.ts` | 95.52 | 100 | 99.41 | 99.48 | +| `src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts` | 93.47 | 100 | 100 | 99.44 | + +Not every remaining branch is reachable by a test. Some of the open branches are defensive +fallbacks that cannot fire at runtime — for example `name.split('/').pop() ?? name` (split +always returns at least one element) and `+(raw.legCount ?? 0)` over a SQL `COUNT(*)`, which is +never null. Covering such a branch would require inventing a mock the data source cannot +actually produce, which proves nothing. The correct fix is to remove the unreachable fallback, +which is also what the project's rule against silent fallbacks calls for. +`ledger-mark-to-market.service.ts` sits at 92.3% branches for exactly this reason. To regenerate the full picture, run the gate and read `coverage-gate/coverage-summary.json`. @@ -196,8 +204,8 @@ To regenerate the full picture, run the gate and read `coverage-gate/coverage-su below 100, the expected response is to extend the tests. Unpinning is an explicit decision that belongs in the PR description, not a silent edit. -That rule stays hard for the 225 logic-carrying files. A foreseeable friction case is different: -when one of the 188 purely declarative files (a NestJS module, a constants file) first gains +That rule stays hard for the 232 logic-carrying files. A foreseeable friction case is different: +when one of the 189 purely declarative files (a NestJS module, a constants file) first gains executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to 0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an allowed outcome if the PR description names and justifies it (not as a silent edit). For diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index b53d2ea1e0..f34fbe6302 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -12,7 +12,7 @@ // 2. The whole suite runs, because a file is frequently covered by specs other than its own. // // The dedicated Frick gate (jest.frick.config.js) stays separate on purpose: it runs ONLY the -// seven Frick specs and therefore proves that those specs alone reach 100% - an assertion this +// ten Frick specs and therefore proves that those specs alone reach 100% - an assertion this // repo-wide run cannot make, because here any spec may contribute the coverage. const base = require('./package.json').jest; @@ -48,15 +48,19 @@ const PINNED_LOGIC = [ 'src/integration/exchange/dto/trade-result.dto.ts', 'src/integration/exchange/enums/exchange.enum.ts', 'src/integration/infrastructure/storage/azure-storage.service.ts', + 'src/integration/infrastructure/storage/mock-storage.service.ts', + 'src/integration/infrastructure/storage/s3-storage.service.ts', 'src/integration/infrastructure/storage/storage.factory.ts', 'src/integration/infrastructure/storage/storage.service.ts', 'src/integration/kucoin-pay/kucoin-pay.dto.ts', 'src/integration/lightning/dto/lnd.dto.ts', 'src/integration/scorechain/dto/scorechain-screening-dto.mapper.ts', + 'src/integration/scorechain/entities/scorechain-screening.entity.ts', 'src/integration/scorechain/exceptions/scorechain-object-not-found.exception.ts', 'src/integration/sift/dto/sift.dto.ts', 'src/polyfills.ts', 'src/shared/auth/allow-tfa-pending.decorator.ts', + 'src/shared/auth/get-jwt.decorator.ts', 'src/shared/auth/user-role.enum.ts', 'src/shared/services/typeorm-logger.ts', 'src/shared/utils/bitbox-ascii.util.ts', @@ -90,6 +94,7 @@ const PINNED_LOGIC = [ 'src/subdomains/core/buy-crypto/routes/buy/dto/personal-iban-provider.enum.ts', 'src/subdomains/core/custody/dto/output/custody-order-history.dto.ts', 'src/subdomains/core/custody/enums/custody.ts', + 'src/subdomains/core/custody/mappers/custody-asset-balance-dto.mapper.ts', 'src/subdomains/core/faucet-request/enums/faucet-request.ts', 'src/subdomains/core/history/dto/history.dto.ts', 'src/subdomains/core/history/dto/output/chain-report-history.dto.ts', @@ -105,6 +110,8 @@ const PINNED_LOGIC = [ 'src/subdomains/core/trading/enums/index.ts', 'src/subdomains/generic/forwarding/controllers/lnurld-forward.controller.ts', 'src/subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts', + 'src/subdomains/generic/gs/dto/db-query.dto.ts', + 'src/subdomains/generic/gs/dto/gs-trigger-type.enum.ts', 'src/subdomains/generic/gs/middleware/debug-query-tree-size.middleware.ts', 'src/subdomains/generic/kyc/dto/ident-result-data.dto.ts', 'src/subdomains/generic/kyc/dto/kyc-error.enum.ts', @@ -378,6 +385,7 @@ const PINNED_DECLARATIVE = [ 'src/subdomains/core/sell-crypto/route/dto/sell.dto.ts', 'src/subdomains/core/sell-crypto/route/dto/unsigned-tx.dto.ts', 'src/subdomains/core/sell-crypto/route/dto/update-sell.dto.ts', + 'src/subdomains/generic/gs/dto/support-data.dto.ts', 'src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts', 'src/subdomains/generic/kyc/dto/input/update-kyc-step.dto.ts', 'src/subdomains/generic/kyc/dto/input/update-name-check-log.dto.ts', @@ -459,7 +467,7 @@ module.exports = { '!**/*.d.ts', '!jest-env.setup.ts', // Test scaffolding that lives outside a __tests__ directory: imported only by specs - // (60 and 28 importers respectively, all of them *.spec.ts). Pinning them would make an + // (62 and 29 importers respectively, all of them *.spec.ts). Pinning them would make an // untested change to a test helper fail the production gate. '!shared/utils/test.util.ts', '!shared/utils/test.shared.module.ts', diff --git a/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts b/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts index f6f5173971..3c1236ebb6 100644 --- a/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts +++ b/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts @@ -84,6 +84,14 @@ describe('MockStorageService', () => { expect(await service.listKeys('nope/')).toEqual([]); }); + + it('lists all entries of the container when no prefix is given', async () => { + const service = new MockStorageService('mock-spec-keys-all'); + await service.uploadBlob('x.png', Buffer.from('x'), 'image/png'); + await service.uploadBlob('y.png', Buffer.from('y'), 'image/png'); + + expect((await service.listKeys()).sort()).toEqual(['x.png', 'y.png']); + }); }); describe('getBlob dummy-file fallback', () => { diff --git a/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts b/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts index c38b10f9b5..979e0071bb 100644 --- a/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts +++ b/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts @@ -355,6 +355,20 @@ describe('S3StorageService', () => { expect(s3Mock.commandCalls(PutObjectCommand)).toHaveLength(0); }); + it('fails closed with a non-Error rejection from the lock-configuration read', async () => { + const container = 'ep2-worm-non-error-rejection'; + s3Mock + .on(GetObjectLockConfigurationCommand, { Bucket: container }) + .callsFake(() => Promise.reject('access denied')); + s3Mock.on(PutObjectCommand).resolves({}); + + await expect( + new S3StorageService(container).uploadWormBlob('settlement.ep2', Buffer.from(''), 'text/xml'), + ).rejects.toThrow('could not verify Object Lock is enabled (error: access denied)'); + + expect(s3Mock.commandCalls(PutObjectCommand)).toHaveLength(0); + }); + it('probes Object Lock on every WORM write (no TTL cache)', async () => { const container = 'ep2-worm-probe-every-write'; s3Mock.on(GetObjectLockConfigurationCommand, { Bucket: container }).resolves({ diff --git a/src/integration/infrastructure/storage/mock-storage.service.ts b/src/integration/infrastructure/storage/mock-storage.service.ts index 1c47006296..3f075c23a5 100644 --- a/src/integration/infrastructure/storage/mock-storage.service.ts +++ b/src/integration/infrastructure/storage/mock-storage.service.ts @@ -67,7 +67,7 @@ export class MockStorageService extends StorageService { }; // Fallback to a dummy file (parity with the previous mock) so LOC document reads return bytes. - const fileName = name.split('/').pop() ?? name; + const fileName = name.slice(name.lastIndexOf('/') + 1); const mapping = DUMMY_FILE_MAP[fileName]; if (mapping) return { diff --git a/src/integration/scorechain/entities/__tests__/scorechain-screening.entity.spec.ts b/src/integration/scorechain/entities/__tests__/scorechain-screening.entity.spec.ts new file mode 100644 index 0000000000..e437e51614 --- /dev/null +++ b/src/integration/scorechain/entities/__tests__/scorechain-screening.entity.spec.ts @@ -0,0 +1,75 @@ +import { ScorechainScreening } from '../scorechain-screening.entity'; + +describe('ScorechainScreening', () => { + describe('rawResponseData', () => { + it('returns undefined when rawResponse is not set', () => { + const screening = Object.assign(new ScorechainScreening(), {}); + + expect(screening.rawResponseData).toBeUndefined(); + }); + + it('round-trips an object through the setter and getter', () => { + const screening = Object.assign(new ScorechainScreening(), {}); + const data = { txHash: '0xabc', score: 12 }; + + screening.rawResponseData = data; + + expect(screening.rawResponse).toBe(JSON.stringify(data)); + expect(screening.rawResponseData).toEqual(data); + }); + + it('sets the field to null when the setter receives null', () => { + const screening = Object.assign(new ScorechainScreening(), { rawResponse: '{"a":1}' }); + + screening.rawResponseData = null; + + expect(screening.rawResponse).toBeNull(); + expect(screening.rawResponseData).toBeUndefined(); + }); + + it('sets the field to null when the setter receives undefined', () => { + const screening = Object.assign(new ScorechainScreening(), { rawResponse: '{"a":1}' }); + + screening.rawResponseData = undefined; + + expect(screening.rawResponse).toBeNull(); + expect(screening.rawResponseData).toBeUndefined(); + }); + }); + + describe('riskIndicatorData', () => { + it('returns undefined when riskIndicators is not set', () => { + const screening = Object.assign(new ScorechainScreening(), {}); + + expect(screening.riskIndicatorData).toBeUndefined(); + }); + + it('round-trips an array through the setter and getter', () => { + const screening = Object.assign(new ScorechainScreening(), {}); + const data = [{ category: 'sanctions', level: 'high' }]; + + screening.riskIndicatorData = data; + + expect(screening.riskIndicators).toBe(JSON.stringify(data)); + expect(screening.riskIndicatorData).toEqual(data); + }); + + it('sets the field to null when the setter receives null', () => { + const screening = Object.assign(new ScorechainScreening(), { riskIndicators: '[1]' }); + + screening.riskIndicatorData = null; + + expect(screening.riskIndicators).toBeNull(); + expect(screening.riskIndicatorData).toBeUndefined(); + }); + + it('sets the field to null when the setter receives undefined', () => { + const screening = Object.assign(new ScorechainScreening(), { riskIndicators: '[1]' }); + + screening.riskIndicatorData = undefined; + + expect(screening.riskIndicators).toBeNull(); + expect(screening.riskIndicatorData).toBeUndefined(); + }); + }); +});