UNPKG

@accounter/server

Version:
1,256 lines (1,096 loc) 47.9 kB
import { Pool } from 'pg'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { connectTestDb } from '../../../../__tests__/helpers/db-connection.js'; import { runMigrationsIfNeeded } from '../../../../__tests__/helpers/db-migrations.js'; import { dropRlsRole, ensureRlsRole, runAsRlsRole } from '../../../../__tests__/helpers/rls-role.js'; import type { AuthContextProvider } from '../../../auth/providers/auth-context.provider.js'; import { DBProvider } from '../../../app-providers/db.provider.js'; import { TenantAwareDBClient } from '../../../app-providers/tenant-db-client.js'; import type { FinancialAccountsProvider } from '../../../financial-accounts/providers/financial-accounts.provider.js'; import type { FinancialBankAccountsProvider } from '../../../financial-accounts/providers/financial-bank-accounts.provider.js'; import type { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js'; import { dateToTimelessDateString } from '../../../../shared/helpers/misc.js'; import { ForeignSecuritiesProvider, MAX_CHARGE_LINK_SECURITIES } from '../foreign-securities.provider.js'; import { SecurityBusinessesProvider } from '../security-businesses.provider.js'; let pool: Pool; // poalim_securities.owner_id is a NOT NULL FK, so the tenants this suite acts as must // exist in accounter_schema.businesses. Seeded in beforeAll, removed in afterAll. const TEST_OWNER_ID = '00000000-0000-0000-0000-0000000006ec'; const OTHER_OWNER_ID = '00000000-0000-0000-0000-0000000006ed'; const CHARGE_ID = '00000000-0000-0000-0000-00000000c001'; const ACCOUNT_ID = '00000000-0000-0000-0000-00000000a001'; // The synthetic security businesses. Fixed ids so cleanup and assertions are deterministic. const APPLE_BUSINESS_ID = '00000000-0000-0000-0000-0000000005a1'; const MSFT_BUSINESS_ID = '00000000-0000-0000-0000-0000000005a2'; const ISIN_ONLY_BUSINESS_ID = '00000000-0000-0000-0000-0000000005a3'; /** * Filler security businesses, used only to push a tenant past * `MAX_CHARGE_LINK_SECURITIES`. Declared here rather than inline so the shared * cleanup covers them — a leaked security business is visible to every other case * in the file, since this suite connects as a superuser and so is not scoped by RLS. */ const OVERFLOW_BUSINESS_IDS = Array.from( { length: MAX_CHARGE_LINK_SECURITIES }, (_unused, index) => `00000000-0000-0000-0000-0000000007${String(index).padStart(2, '0')}`, ); const SECURITY_BUSINESS_IDS = [ APPLE_BUSINESS_ID, MSFT_BUSINESS_ID, ISIN_ONLY_BUSINESS_ID, ...OVERFLOW_BUSINESS_IDS, ]; /** * ISINs here are synthetic (`ZZ…`) rather than real ones. * * The securities lookups carry no `owner_id` predicate — RLS does that, and this * suite connects as a superuser which bypasses it — so every security business in * the database is visible to a lookup by ISIN. Sharing a real ISIN with * `security-businesses.integration.test.ts`, which runs concurrently and asserts * on the row it gets back for one, is a genuine cross-suite collision. */ const BANK_NUMBER = 12; const BRANCH_NUMBER = 615; const ACCOUNT_NUMBER = 100000; function createMockAuthContextProvider(businessId: string): AuthContextProvider { return { getAuthContext: () => Promise.resolve({ authType: 'apiKey' as const, token: 'test-token', tenant: { businessId }, user: { userId: 'api-key:test', auth0UserId: null, email: '', roleId: 'admin', permissions: [], emailVerified: true, permissionsVersion: 0, }, }), } as unknown as AuthContextProvider; } type StubTransaction = { id: string; source_description: string | null; amount?: string; currency?: string; debit_date?: Date | null; debit_date_override?: Date | null; account_id?: string; }; /** The day the fixtures settle on: execution value date and transaction debit date alike. */ const VALUE_DATE = '2024-03-12'; /** * The provider reads only the identity, description, account and date/amount fields off each * transaction. Stubbing the loader keeps these cases focused on the DB-dependent behaviour * (RLS scoping, the freshest-row pick, execution matching) instead of seeding the * financial_accounts → transactions_raw_list → transactions FK chain. */ function createStubTransactionsProvider(transactions: StubTransaction[]): TransactionsProvider { return { transactionsByChargeIDLoader: { load: (chargeId: string) => Promise.resolve(transactions.map(row => withDefaults(chargeId, row))), }, // The reverse direction, used by `getSecurityBusinessHistory`: the candidate cash // movements are the security business's own transactions. getTransactionsByFilters: () => Promise.resolve(transactions.map(row => withDefaults(CHARGE_ID, row))), } as unknown as TransactionsProvider; } function withDefaults(chargeId: string, transaction: StubTransaction) { return { charge_id: chargeId, amount: '-1000.00', currency: 'USD', debit_date: new Date(`${VALUE_DATE}T00:00:00`), debit_date_override: null, account_id: ACCOUNT_ID, ...transaction, }; } /** * The account lookups are pure id → row maps in the real providers; stubbing them keeps this * suite off the financial_accounts fixtures while still exercising the tuple resolution * (text account_number → integer) the provider does. */ function createStubFinancialAccountsProvider(accountNumber = String(ACCOUNT_NUMBER)) { return { getFinancialAccountByAccountIDLoader: { load: (id: string) => Promise.resolve(id === ACCOUNT_ID ? { id, account_number: accountNumber } : undefined), }, } as unknown as FinancialAccountsProvider; } function createStubFinancialBankAccountsProvider() { return { getFinancialBankAccountByIdLoader: { load: (id: string) => Promise.resolve( id === ACCOUNT_ID ? { id, bank_number: BANK_NUMBER, branch_number: BRANCH_NUMBER } : undefined, ), }, } as unknown as FinancialBankAccountsProvider; } function createProvider( transactions: StubTransaction[], businessId = TEST_OWNER_ID, accountNumber?: string, ) { const authContextProvider = createMockAuthContextProvider(businessId); const dbClient = new TenantAwareDBClient(new DBProvider(pool), authContextProvider); return new ForeignSecuritiesProvider( dbClient, createStubTransactionsProvider(transactions), createStubFinancialAccountsProvider(accountNumber), createStubFinancialBankAccountsProvider(), // The holdings path reads security businesses and their identifiers, so this one has to be // real. Only `db` is exercised: the four remaining constructor deps drive the create path, // which these cases seed around by inserting the rows directly. new SecurityBusinessesProvider( dbClient, {} as never, {} as never, {} as never, {} as never, ), ); } type SecurityFixture = { ownerId?: string; branchNumber?: number; accountNumber?: number; securityKey: string; engName?: string; asOfDate?: string; }; // Synthetic values only — never lifted from a real bank capture. async function insertSecurity({ ownerId = TEST_OWNER_ID, branchNumber = 615, accountNumber = 100000, securityKey, engName = 'Example Corp', asOfDate = '2024-01-15T10:00:00.000+02:00', }: SecurityFixture) { await pool.query( `INSERT INTO accounter_schema.poalim_securities ( owner_id, bank_number, branch_number, account_number, as_of_date, security_key, eng_name, heb_name, item_type, is_etf, is_foreign, currency_code, exchange, equity_type, equity_sub_type, eng_symbol, heb_symbol, symbol, stock_type ) VALUES ($1, 12, $2, $3, $4, $5, $6, 'אקזמפל', 'Equity', false, true, 'USD', 'NYQ', 1, 1, 'EXMP', 'EXMP', 'EXMP', 'Equity')`, [ownerId, branchNumber, accountNumber, asOfDate, securityKey, engName], ); } type ExecutionFixture = { ownerId?: string; branchNumber?: number; accountNumber?: number; security: string; tradeDate?: string; valueDate?: string | null; tradeType?: string; /** Defaults to `tradeType`, which is the invariant on a plain buy or sale. */ transactionType?: string; netValueTradeCurrency?: string; /** Units moved. Fractional on purpose in the holdings cases — ETFs trade that way. */ nv?: string; }; // Synthetic values only — never lifted from a real bank capture. async function insertExecution({ ownerId = TEST_OWNER_ID, branchNumber = BRANCH_NUMBER, accountNumber = ACCOUNT_NUMBER, security, tradeDate = '2024-03-10', valueDate = VALUE_DATE, tradeType = 'קניה', transactionType, netValueTradeCurrency = '1000.00', nv = '10', }: ExecutionFixture) { // trade_type/transaction_type carry the bank's own Hebrew vocabulary; on a plain buy or sale // the two agree (an invariant the scraper's zod schema enforces). await pool.query( `INSERT INTO accounter_schema.poalim_securities_transactions ( owner_id, bank_number, branch_number, account_number, security, trade_date, value_date, trade_type, transaction_type, nv, trade_price, net_value_trade_currency, trade_currency ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $11, $10, 100, $9, 'דולר ארה"ב')`, [ ownerId, BANK_NUMBER, branchNumber, accountNumber, security, tradeDate, valueDate, tradeType, netValueTradeCurrency, nv, transactionType ?? tradeType, ], ); } /** * A security business and the Poalim keys it answers to, inserted directly rather than through * `SecurityBusinessesProvider.ensureSecurityBusiness` — that path needs an admin context and a * general foreign-securities business, neither of which the holdings query reads. */ async function insertSecurityBusiness({ id, isin, engName = 'Example Corp', securityKeys = [], ownerId = TEST_OWNER_ID, }: { id: string; isin: string; engName?: string; securityKeys?: string[]; ownerId?: string; }) { await pool.query( `INSERT INTO accounter_schema.financial_entities (id, owner_id, name, type) VALUES ($1, $2, $3, 'business')`, [id, ownerId, `${engName} (${isin})`], ); await pool.query( `INSERT INTO accounter_schema.businesses (id, owner_id) VALUES ($1, $2)`, [id, ownerId], ); await pool.query( `INSERT INTO accounter_schema.businesses_securities (id, owner_id, isin, eng_name, symbol) VALUES ($1, $2, $3, $4, 'EXMP')`, [id, ownerId, isin, engName], ); for (const securityKey of securityKeys) { await pool.query( `INSERT INTO accounter_schema.security_identifiers (owner_id, business_id, identifier_type, identifier_value) VALUES ($1, $2, 'POALIM_SECURITY_KEY', $3)`, [ownerId, id, securityKey], ); } } beforeAll(async () => { pool = await connectTestDb(); await runMigrationsIfNeeded(pool); for (const [index, ownerId] of [TEST_OWNER_ID, OTHER_OWNER_ID].entries()) { await pool.query( `INSERT INTO accounter_schema.financial_entities (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [ownerId, `foreign-securities-test-owner-${index}`], ); await pool.query( `INSERT INTO accounter_schema.businesses (id, owner_id) VALUES ($1, $1) ON CONFLICT (id) DO NOTHING`, [ownerId], ); } await ensureRlsRole(pool, { grants: [ // DELETE/UPDATE as well as SELECT: the write-target cases below have to reach the policy // check rather than failing on a missing privilege, which would pass for the wrong reason. { table: 'poalim_securities', privileges: 'SELECT, UPDATE, DELETE' }, { table: 'poalim_securities_transactions', privileges: 'SELECT, UPDATE, DELETE' }, { table: 'businesses_securities', privileges: 'SELECT, UPDATE, DELETE' }, { table: 'security_identifiers', privileges: 'SELECT, UPDATE, DELETE' }, ], }); }); afterAll(async () => { await dropRlsRole(pool); await deleteSecurityBusinesses(); // poalim_securities and poalim_securities_transactions rows cascade with the owning business. for (const ownerId of [TEST_OWNER_ID, OTHER_OWNER_ID]) { await pool.query('DELETE FROM accounter_schema.businesses WHERE id = $1', [ownerId]); await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = $1', [ownerId]); } // Do NOT close the pool here — it is shared with other concurrently-running suites and // torn down by vitest-global-setup. }); // DELETE rather than TRUNCATE CASCADE: TRUNCATE takes ACCESS EXCLUSIVE locks that deadlock // against concurrently-running integration suites. // Scoped to this suite's owners: the poalim_* tables are shared with the concurrently-running // scraper-ingestion suite, and an unqualified DELETE wipes its fixtures mid-test. beforeEach(async () => { const owners = [TEST_OWNER_ID, OTHER_OWNER_ID]; await pool.query('DELETE FROM accounter_schema.poalim_securities WHERE owner_id = ANY($1)', [ owners, ]); await pool.query( 'DELETE FROM accounter_schema.poalim_securities_transactions WHERE owner_id = ANY($1)', [owners], ); await deleteSecurityBusinesses(); }); /** * The synthetic security businesses, by explicit id so the tenants' own business rows survive * between tests. `businesses` first: its FK to `financial_entities` does not cascade, while * businesses_securities and security_identifiers do cascade from `businesses`. */ async function deleteSecurityBusinesses() { await pool.query('DELETE FROM accounter_schema.businesses WHERE id = ANY($1)', [ SECURITY_BUSINESS_IDS, ]); await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = ANY($1)', [ SECURITY_BUSINESS_IDS, ]); } describe('getChargeSecurities', () => { it('resolves a key from a transaction description to its ingested security', async () => { await insertSecurity({ securityKey: '5129523', engName: 'Example Corp' }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז מכירה 0005129523' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities).toHaveLength(1); expect(securities[0].securityKey).toBe('5129523'); expect(securities[0].id).toBe(`${CHARGE_ID}-5129523`); expect(securities[0].details?.eng_name).toBe('Example Corp'); expect(securities[0].transactionIds).toEqual(['t1']); }); it('returns an unresolved entry when the key has no ingested row', async () => { const provider = createProvider([ { id: 't1', source_description: 'ניע"ז קניה 0077774297' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities).toHaveLength(1); expect(securities[0].securityKey).toBe('77774297'); expect(securities[0].details).toBeNull(); expect(securities[0].transactionIds).toEqual(['t1']); }); it('groups every transaction carrying the same key under one entry', async () => { await insertSecurity({ securityKey: '5129523' }); const provider = createProvider([ { id: 'trade', source_description: 'ניע"ז קניה 0005129523' }, { id: 'fee', source_description: 'ניע"ז עמ קניה 0005129523' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities).toHaveLength(1); expect(securities[0].transactionIds).toEqual(['trade', 'fee']); }); it('returns an empty array when no description carries a key', async () => { await insertSecurity({ securityKey: '5129523' }); const provider = createProvider([ { id: 't1', source_description: 'ניעז עמ תשלום fsec pymnt fee' }, { id: 't2', source_description: null }, ]); expect(await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID)).toEqual([]); }); it('returns one entry per distinct key on a merged charge', async () => { await insertSecurity({ securityKey: '5129523', engName: 'Example Corp' }); await insertSecurity({ securityKey: '77774297', engName: 'Other Corp', accountNumber: 100001, }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז מכירה 0005129523' }, { id: 't2', source_description: 'ניע"ז קניה 0077774297' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities.map(s => s.securityKey)).toEqual(['5129523', '77774297']); expect(securities.map(s => s.details?.eng_name)).toEqual(['Example Corp', 'Other Corp']); }); it('picks the freshest row when one tenant holds the key in several accounts', async () => { // The dedup index is (owner_id, bank, branch, account, key), so this is reachable. await insertSecurity({ securityKey: '5129523', accountNumber: 100000, engName: 'Stale Name', asOfDate: '2024-01-15T10:00:00.000+02:00', }); await insertSecurity({ securityKey: '5129523', accountNumber: 100001, engName: 'Fresh Name', asOfDate: '2024-06-30T10:00:00.000+02:00', }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז מכירה 0005129523' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities).toHaveLength(1); expect(securities[0].details?.eng_name).toBe('Fresh Name'); }); /** * The provider's query carries no owner_id predicate — RLS is what scopes it. The test * pool connects as postgres (BYPASSRLS), so asserting that through the provider would * prove nothing; this runs the same query shape under a non-superuser role, which is * the boundary the server actually operates behind. */ it("does not expose another tenant's securities under the tenant_isolation policy", async () => { await insertSecurity({ ownerId: OTHER_OWNER_ID, securityKey: '5129523' }); const client = await pool.connect(); try { await client.query('BEGIN'); // Session variables must be set as superuser, before privileges are dropped. await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [TEST_OWNER_ID]); const rows = await runAsRlsRole(client, async () => { const result = await client.query( `SELECT security_key FROM accounter_schema.poalim_securities WHERE security_key = ANY($1)`, [['5129523']], ); return result.rows; }); expect(rows).toEqual([]); } finally { await client.query('ROLLBACK'); client.release(); } }); }); describe('getChargeSecurities — matched executions', () => { it('attaches the execution behind the charge transaction', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523', netValueTradeCurrency: '1000.00' }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions).toHaveLength(1); expect(securities[0].executions[0].trade_type).toBe('קניה'); expect(securities[0].executions[0].net_value_trade_currency).toBe('1000.00'); }); it('picks the same-day execution whose amount matches the transaction', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523', netValueTradeCurrency: '1000.00' }); await insertExecution({ security: '5129523', netValueTradeCurrency: '2500.50', tradeType: 'מכירה', }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז מכירה 0005129523', amount: '2500.50' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions.map(e => e.net_value_trade_currency)).toEqual(['2500.50']); }); it('excludes an execution booked in another account', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523', accountNumber: 100001 }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions).toEqual([]); }); it('excludes an execution whose amount does not match', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523', netValueTradeCurrency: '4321.00' }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions).toEqual([]); }); it('excludes an execution that settles on another day', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523', valueDate: '2024-01-05' }); const provider = createProvider([ { id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }, ]); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions).toEqual([]); }); it('returns no executions when the account number is not a Poalim integer', async () => { await insertSecurity({ securityKey: '5129523' }); await insertExecution({ security: '5129523' }); const provider = createProvider( [{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }], TEST_OWNER_ID, 'IL12-3456', ); const securities = await provider.getChargeSecurities(CHARGE_ID, TEST_OWNER_ID); expect(securities[0].executions).toEqual([]); }); it("does not expose another tenant's executions under the tenant_isolation policy", async () => { await insertExecution({ ownerId: OTHER_OWNER_ID, security: '5129523' }); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [TEST_OWNER_ID]); const rows = await runAsRlsRole(client, async () => { const result = await client.query( `SELECT security FROM accounter_schema.poalim_securities_transactions WHERE security = ANY($1)`, [['5129523']], ); return result.rows; }); expect(rows).toEqual([]); } finally { await client.query('ROLLBACK'); client.release(); } }); }); /** This suite's own buckets out of a tenant-wide result. See the note on ISINs above. */ function ownedBuckets<T>(byBusinessId: Map<string, T[]>): T[][] { return SECURITY_BUSINESS_IDS.map(id => byBusinessId.get(id)).filter( (bucket): bucket is T[] => bucket !== undefined, ); } describe('getExecutionsBySecurityBusiness', () => { it('gives every security business its own executions, chronologically', async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000601', engName: 'APPLE INC', securityKeys: ['1097'], }); await insertSecurityBusiness({ id: MSFT_BUSINESS_ID, isin: 'ZZ0000000602', engName: 'MICROSOFT CORP', securityKeys: ['2044'], }); await insertExecution({ security: '1097', tradeDate: '2024-03-10' }); await insertExecution({ security: '1097', tradeDate: '2024-01-05' }); await insertExecution({ security: '2044', tradeDate: '2024-02-02' }); const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)).toHaveLength(2); // The SQL orders globally by trade_date, so each business's slice stays chronological — // which is what the position derivation reads its currency and start date off. expect( executionsByBusinessId.get(APPLE_BUSINESS_ID)?.map(execution => execution.trade_date), ).toEqual([new Date('2024-01-05T00:00:00'), new Date('2024-03-10T00:00:00')]); expect(executionsByBusinessId.get(MSFT_BUSINESS_ID)).toHaveLength(1); }); it('collapses several Poalim keys onto the one security business they name', async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000601', securityKeys: ['1097', '1098'], }); await insertExecution({ security: '1097' }); await insertExecution({ security: '1098' }); const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)).toHaveLength(2); }); it('keeps a security business with no Poalim key, with nothing against it', async () => { // Its ISIN was ingested but no execution ever named it by key. It is still a security the // tenant has, so it has to stay listable — a zero position, not a missing row. await insertSecurityBusiness({ id: ISIN_ONLY_BUSINESS_ID, isin: 'IL0010811243', securityKeys: [], }); const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); expect(executionsByBusinessId.has(ISIN_ONLY_BUSINESS_ID)).toBe(true); expect(executionsByBusinessId.get(ISIN_ONLY_BUSINESS_ID)).toEqual([]); }); it('ignores executions whose key belongs to no security business', async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000601', securityKeys: ['1097'], }); await insertExecution({ security: '1097' }); await insertExecution({ security: '9999' }); const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); // Asserted on this suite's own buckets rather than on the map's size: the // query carries no owner predicate — RLS is what scopes it in production, and // this suite connects as a superuser which bypasses it — so a concurrently // running suite's security businesses are legitimately in the result. expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)).toHaveLength(1); // '9999' belongs to no security business, so it is nowhere in the map. expect(ownedBuckets(executionsByBusinessId).flat()).toHaveLength(1); }); it('gives a security business with nothing ingested an empty bucket, not none', async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000601' }); await insertExecution({ security: '1097' }); const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); // Apple carries no POALIM_SECURITY_KEY identifier, so the execution cannot // resolve to it — but the business still gets an entry, which is what keeps // "nothing ingested" distinguishable from "not a security". expect(executionsByBusinessId.has(APPLE_BUSINESS_ID)).toBe(true); expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)).toEqual([]); }); /** * As with the poalim_* tables above, a provider-level assertion proves nothing here: the * suite connects as a superuser, who bypasses RLS. This runs the query shape the holdings * path depends on under the non-superuser role the server actually operates behind. */ it("does not expose another tenant's security businesses under tenant_isolation", async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000601', securityKeys: ['1097'], ownerId: OTHER_OWNER_ID, }); const client = await pool.connect(); try { await client.query('BEGIN'); // Session variables must be set as superuser, before privileges are dropped. await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [TEST_OWNER_ID]); const rows = await runAsRlsRole(client, async () => { const result = await client.query( `SELECT bs.isin, si.identifier_value FROM accounter_schema.businesses_securities bs LEFT JOIN accounter_schema.security_identifiers si ON si.business_id = bs.id`, ); return result.rows; }); expect(rows).toEqual([]); } finally { await client.query('ROLLBACK'); client.release(); } }); }); /** * `Query.securityExecutions` behind the provider: the SQL-pushdown path, the * unpaginated match path behind `includeCharges`, and the filter resolution both * share. */ describe('getSecurityExecutionsPage', () => { const APPLE_ISIN = 'ZZ0000000601'; const MSFT_ISIN = 'ZZ0000000602'; /** * Every case narrows to this suite's own two securities by default. * * Omitting the identity filter means "every security this tenant has", and the * suite connects as a superuser — so RLS does not scope the read and a * concurrently-running suite's fixtures would land in the result. Only the one * case that is actually about the unfiltered behaviour leaves this out, and it * asserts containment rather than an exact count. */ const page = ( overrides: Partial<Parameters<ForeignSecuritiesProvider['getSecurityExecutionsPage']>[0]> = {}, ) => createProvider([]).getSecurityExecutionsPage({ page: 0, limit: 50, includeCharges: false, ownerId: TEST_OWNER_ID, ...overrides, filters: { isins: [APPLE_ISIN, MSFT_ISIN], ...overrides.filters }, }); beforeEach(async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: APPLE_ISIN, engName: 'Apple', securityKeys: ['1097'], }); await insertSecurityBusiness({ id: MSFT_BUSINESS_ID, isin: MSFT_ISIN, engName: 'Microsoft', securityKeys: ['2098'], }); }); it('is empty, without erroring, when the tenant has no matching security', async () => { const result = await createProvider([]).getSecurityExecutionsPage({ filters: { isins: ['ZZ0000000699'] }, page: 0, limit: 50, includeCharges: false, ownerId: TEST_OWNER_ID, }); expect(result.nodes).toEqual([]); expect(result.totalRecords).toBe(0); }); it('covers every security when no identity filter is given', async () => { await insertExecution({ security: '1097' }); await insertExecution({ security: '2098' }); const result = await createProvider([]).getSecurityExecutionsPage({ filters: {}, page: 0, limit: 500, includeCharges: false, ownerId: TEST_OWNER_ID, }); // Containment, not equality: with no identity filter and no RLS under a // superuser connection, a concurrent suite's fixtures can be in here too. const businessIds = new Set(result.nodes.map(node => node.securityBusinessId)); expect(businessIds).toContain(APPLE_BUSINESS_ID); expect(businessIds).toContain(MSFT_BUSINESS_ID); }); it('orders newest first — the opposite of the history query', async () => { await insertExecution({ security: '1097', tradeDate: '2024-01-01' }); await insertExecution({ security: '1097', tradeDate: '2024-06-01' }); await insertExecution({ security: '1097', tradeDate: '2024-03-01' }); const result = await page(); expect(result.nodes.map(node => dateToTimelessDateString(node.execution.trade_date))).toEqual([ '2024-06-01', '2024-03-01', '2024-01-01', ]); }); it('reports the full match count alongside a page of it', async () => { for (const day of ['01', '02', '03', '04', '05']) { await insertExecution({ security: '1097', tradeDate: `2024-03-${day}` }); } const first = await page({ limit: 2, page: 0 }); const second = await page({ limit: 2, page: 1 }); expect(first.totalRecords).toBe(5); expect(first.nodes).toHaveLength(2); expect(second.totalRecords).toBe(5); // Consecutive pages must not overlap or skip. expect(second.nodes.map(node => node.id)).not.toEqual(first.nodes.map(node => node.id)); }); /** * The three identity filters name the same axis three ways, so they union. * Intersecting them would make "this ISIN and that symbol" mean the empty * overlap, which is never what a caller means. */ it('unions the identity filters rather than intersecting them', async () => { await insertExecution({ security: '1097' }); await insertExecution({ security: '2098' }); const result = await createProvider([]).getSecurityExecutionsPage({ filters: { isins: [APPLE_ISIN], securityBusinessIds: [MSFT_BUSINESS_ID] }, page: 0, limit: 50, includeCharges: false, ownerId: TEST_OWNER_ID, }); expect(result.totalRecords).toBe(2); }); it('matches symbols case-insensitively', async () => { await insertExecution({ security: '1097' }); const businessesFor = async (symbol: string) => { const result = await createProvider([]).getSecurityExecutionsPage({ filters: { symbols: [symbol] }, page: 0, limit: 500, includeCharges: false, ownerId: TEST_OWNER_ID, }); return new Set(result.nodes.map(node => node.securityBusinessId)); }; // insertSecurityBusiness writes the symbol as 'EXMP', so the lowercase spelling // has to reach it. Asserted by which security came back rather than by a count: // with no ISIN filter and no RLS under a superuser connection, a concurrent // suite's fixtures can share the result. expect(await businessesFor('exmp')).toContain(APPLE_BUSINESS_ID); expect(await businessesFor('nope')).not.toContain(APPLE_BUSINESS_ID); }); it('ignores an id that is not one of this tenant security businesses', async () => { await insertExecution({ security: '1097' }); const result = await createProvider([]).getSecurityExecutionsPage({ filters: { securityBusinessIds: [OTHER_OWNER_ID] }, page: 0, limit: 50, includeCharges: false, ownerId: TEST_OWNER_ID, }); expect(result.totalRecords).toBe(0); }); it('pushes the trade-date range into SQL', async () => { await insertExecution({ security: '1097', tradeDate: '2024-01-15' }); await insertExecution({ security: '1097', tradeDate: '2024-05-15' }); const bounded = await page({ filters: { fromTradeDate: '2024-04-01', toTradeDate: '2024-06-30' }, }); expect(bounded.totalRecords).toBe(1); expect(dateToTimelessDateString(bounded.nodes[0]!.execution.trade_date)).toBe('2024-05-15'); }); it('filters on the bank own labels for trade and transaction type', async () => { await insertExecution({ security: '1097', tradeType: 'קניה' }); await insertExecution({ security: '1097', tradeType: 'מכירה' }); expect((await page({ filters: { rawTradeTypes: ['מכירה'] } })).totalRecords).toBe(1); expect((await page({ filters: { rawTransactionTypes: ['קניה'] } })).totalRecords).toBe(1); // An empty list is "no restriction", not "match nothing" — the `is*` flag guard. expect((await page({ filters: { rawTradeTypes: [] } })).totalRecords).toBe(2); expect((await page()).totalRecords).toBe(2); }); it('carries the security business on every row, so a flat list can be grouped', async () => { await insertExecution({ security: '2098' }); const result = await page(); expect(result.nodes[0]!.securityBusinessId).toBe(MSFT_BUSINESS_ID); // Not asked for, so no pairing was attempted. expect(result.nodes[0]!.transaction).toBeNull(); }); it('refuses to pair charge links across more securities than it can', async () => { // Two are seeded already; take the tenant past the cap so an unnarrowed // request has to be refused. for (const [index, id] of OVERFLOW_BUSINESS_IDS.entries()) { await insertSecurityBusiness({ id, isin: `ZZ9${String(index).padStart(9, '0')}`, securityKeys: [`90${index}`], }); } await insertExecution({ security: '1097' }); await expect( createProvider([]).getSecurityExecutionsPage({ filters: {}, page: 0, limit: 50, includeCharges: true, ownerId: TEST_OWNER_ID, }), ).rejects.toThrow(/more than the \d+ it can pair at once/); }); it('pairs charge links when the filter names few enough securities', async () => { await insertExecution({ security: '1097' }); const result = await createProvider([]).getSecurityExecutionsPage({ filters: { isins: [APPLE_ISIN] }, page: 0, limit: 50, includeCharges: true, ownerId: TEST_OWNER_ID, }); expect(result.totalRecords).toBe(1); expect(result.nodes[0]!.securityBusinessId).toBe(APPLE_BUSINESS_ID); }); /** * The two paths must agree about what page 1 is, or paging with and without * charge links would return different rows for the same request. */ it('orders the match path identically to the SQL path', async () => { await insertExecution({ security: '1097', tradeDate: '2024-01-01' }); await insertExecution({ security: '1097', tradeDate: '2024-06-01' }); await insertExecution({ security: '1097', tradeDate: '2024-03-01' }); const pushdown = await page({ filters: { isins: [APPLE_ISIN] } }); const matched = await createProvider([]).getSecurityExecutionsPage({ filters: { isins: [APPLE_ISIN] }, page: 0, limit: 50, includeCharges: true, ownerId: TEST_OWNER_ID, }); expect(matched.nodes.map(node => node.id)).toEqual(pushdown.nodes.map(node => node.id)); }); it('applies the date and type filters on the match path too', async () => { await insertExecution({ security: '1097', tradeDate: '2024-01-15', tradeType: 'קניה' }); await insertExecution({ security: '1097', tradeDate: '2024-05-15', tradeType: 'מכירה' }); const result = await createProvider([]).getSecurityExecutionsPage({ filters: { isins: [APPLE_ISIN], fromTradeDate: '2024-04-01', rawTradeTypes: ['מכירה'], }, page: 0, limit: 50, includeCharges: true, ownerId: TEST_OWNER_ID, }); expect(result.totalRecords).toBe(1); expect(result.nodes[0]!.execution.trade_type).toBe('מכירה'); }); /** * Tenant isolation is deliberately NOT asserted here: this suite connects as a * superuser, who bypasses RLS, so a green provider-level assertion would prove * nothing. The `multi-business read scope` cases below run the same tables under * the non-superuser role the server actually operates behind. */ }); /** * A Poalim security key is unique only *within* an owner. * * `security_identifiers` is unique on `(owner_id, identifier_type, identifier_value)`, so two * businesses that both trade one security each carry it under the same key — the ordinary case for * a tenant with more than one business, not an exotic one. Reads follow the request's whole * business scope, so both rows are visible at once and a key-only lookup has nothing to tell them * apart: it keeps whichever was seen last and files one business's trades under the other's * security. * * These cases run as the suite's superuser, which bypasses RLS — which is exactly the widest * version of the situation, and what makes them a regression test for the join rather than for the * policy. */ describe('two businesses trading the same security', () => { const SHARED_KEY = '1097'; beforeEach(async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000621', engName: 'Shared Security (mine)', securityKeys: [SHARED_KEY], }); await insertSecurityBusiness({ id: MSFT_BUSINESS_ID, isin: 'ZZ0000000622', engName: 'Shared Security (theirs)', securityKeys: [SHARED_KEY], ownerId: OTHER_OWNER_ID, }); await insertExecution({ security: SHARED_KEY, tradeDate: '2024-03-01' }); await insertExecution({ ownerId: OTHER_OWNER_ID, security: SHARED_KEY, tradeDate: '2024-03-02', }); }); it('gives each business only its own executions', async () => { const executionsByBusinessId = await createProvider([]).getExecutionsBySecurityBusiness(); expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)).toHaveLength(1); expect(executionsByBusinessId.get(MSFT_BUSINESS_ID)).toHaveLength(1); expect(executionsByBusinessId.get(APPLE_BUSINESS_ID)![0]!.owner_id).toBe(TEST_OWNER_ID); expect(executionsByBusinessId.get(MSFT_BUSINESS_ID)![0]!.owner_id).toBe(OTHER_OWNER_ID); }); it('keeps a filtered page to the business it named', async () => { const result = await createProvider([]).getSecurityExecutionsPage({ filters: { securityBusinessIds: [APPLE_BUSINESS_ID] }, page: 0, limit: 50, includeCharges: false, ownerId: TEST_OWNER_ID, }); expect(result.totalRecords).toBe(1); expect(result.nodes[0]!.securityBusinessId).toBe(APPLE_BUSINESS_ID); expect(result.nodes[0]!.execution.owner_id).toBe(TEST_OWNER_ID); }); it("keeps one business's history out of the other's", async () => { const { executions } = await createProvider([]).getSecurityBusinessHistory( MSFT_BUSINESS_ID, OTHER_OWNER_ID, ); expect(executions).toHaveLength(1); expect(executions[0]!.owner_id).toBe(OTHER_OWNER_ID); }); it('resolves the reference details per owner', async () => { await insertSecurity({ securityKey: SHARED_KEY, engName: 'Mine' }); await insertSecurity({ ownerId: OTHER_OWNER_ID, securityKey: SHARED_KEY, engName: 'Theirs', }); const provider = createProvider([]); const [mine, theirs] = await Promise.all([ provider.securityByKeyLoader.load({ ownerId: TEST_OWNER_ID, securityKey: SHARED_KEY }), provider.securityByKeyLoader.load({ ownerId: OTHER_OWNER_ID, securityKey: SHARED_KEY }), ]); expect(mine?.eng_name).toBe('Mine'); expect(theirs?.eng_name).toBe('Theirs'); }); }); /** * The read predicate on all four securities tables was pinned to the singular * `get_current_business_id()` until this was fixed, so a request whose authorized * scope spanned several businesses silently saw only one of them. A * provider-level assertion proves nothing — this suite connects as a superuser, * who bypasses RLS — so this runs under the non-superuser role the server * actually operates behind. */ describe('multi-business read scope', () => { async function readUnderScope( table: string, scope: string[] | null, currentBusinessId = TEST_OWNER_ID, ) { const client = await pool.connect(); try { await client.query('BEGIN'); // Session variables must be set as superuser, before privileges are dropped. await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [ currentBusinessId, ]); await client.query(`SELECT set_config('app.current_business_scope', $1, true)`, [ scope ? `{${scope.join(',')}}` : '', ]); return await runAsRlsRole(client, async () => { const result = await client.query( `SELECT owner_id FROM accounter_schema.${table} ORDER BY owner_id`, ); return result.rows.map((row: { owner_id: string }) => row.owner_id); }); } finally { await client.query('ROLLBACK'); client.release(); } } beforeEach(async () => { await insertSecurityBusiness({ id: APPLE_BUSINESS_ID, isin: 'ZZ0000000611', securityKeys: ['1097'], }); await insertSecurityBusiness({ id: MSFT_BUSINESS_ID, isin: 'ZZ0000000612', securityKeys: ['2098'], ownerId: OTHER_OWNER_ID, }); await insertSecurity({ securityKey: '1097' }); await insertSecurity({ ownerId: OTHER_OWNER_ID, securityKey: '2098' }); await insertExecution({ security: '1097' }); await insertExecution({ ownerId: OTHER_OWNER_ID, security: '2098' }); }); const TABLES = [ 'poalim_securities', 'poalim_securities_transactions', 'businesses_securities', 'security_identifiers', ]; it.each(TABLES)('%s returns every business in the scope', async table => { const owners = await readUnderScope(table, [TEST_OWNER_ID, OTHER_OWNER_ID]); expect(new Set(owners)).toEqual(new Set([TEST_OWNER_ID, OTHER_OWNER_ID])); }); it.each(TABLES)('%s narrows to a single-business scope', async table => { const owners = await readUnderScope(table, [TEST_OWNER_ID]); expect(new Set(owners)).toEqual(new Set([TEST_OWNER_ID])); }); /** * `get_current_business_scope()` falls back to `ARRAY[get_current_business_id()]` * when the GUC is unset, so a caller that never sets a scope behaves exactly as * it did before the predicate was widened. */ it.each(TABLES)('%s falls back to the single business when no scope is set', async table => { const owners = await readUnderScope(table, null); expect(new Set(owners)).toEqual(new Set([TEST_OWNER_ID])); }); /** * Reads follow the scope; writes do not. * * `USING` selects the rows a statement may act on, and Postgres consults it for DELETE and * UPDATE as well as SELECT — `WITH CHECK` only constrains the *new* values. So the permissive * scope-wide policy on its own would let a session delete another in-scope business's row, or * update one into its own ownership. The restrictive per-command policies are what stop that, * and these cases are the proof: the row is readable, and neither writable. */ async function writeUnderScope( statement: 'delete' | 'update', table: string, scope: string[], targetOwnerId: string, currentBusinessId = TEST_OWNER_ID, ): Promise<number> { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [ currentBusinessId, ]); await client.query(`SELECT set_config('app.current_business_scope', $1, true)`, [ `{${scope.join(',')}}`, ]); return await runAsRlsRole(client, async () => { const sql = statement === 'delete' ? `DELETE FROM accounter_schema.${table} WHERE owner_id = $1` : `UPDATE accounter_schema.${table} SET owner_id = owner_id WHERE owner_id = $1`; const result = await client.query(sql, [targetOwnerId]); return result.rowCount ?? 0; }); } finally { await client.query('ROLLBACK'); client.release(); } } it.each(TABLES)('%s refuses to delete another business in the scope', async table => { const scope = [TEST_OWNER_ID, OTHER_OWNER_ID]; // Visible... expect(await readUnderScope(table, scope)).toContain(OTHER_OWNER_ID); // ...and still not deletable, because the write target is the other business. expect(await writeUnderScope('delete', table, scope, OTHER_OWNER_ID)).toBe(0); }); it.each(TABLES)('%s refuses to update another business in the scope', async table => { const scope = [TEST_OWNER_ID, OTHER_OWNER_ID]; expect(await writeUnderScope('update', table, scope, OTHER_OWNER_ID)).toBe(0); }); it.each(TABLES)('%s still lets the write target delete its own rows', async table => { const scope = [TEST_OWNER_ID, OTHER_OWNER_ID]; expect(await writeUnderScope('delete', table, scope, TEST_OWNER_ID)).toBeGreaterThan(0); }); });