@accounter/server
Version:
Accounter GraphQL server
333 lines (287 loc) • 12.2 kB
text/typescript
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 { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js';
import { DBProvider } from '../../../app-providers/db.provider.js';
import { TenantAwareDBClient } from '../../../app-providers/tenant-db-client.js';
import type { AuthContextProvider } from '../../../auth/providers/auth-context.provider.js';
import { BusinessesProvider } from '../../../financial-entities/providers/businesses.provider.js';
import { FinancialEntitiesProvider } from '../../../financial-entities/providers/financial-entities.provider.js';
import { TaxCategoriesProvider } from '../../../financial-entities/providers/tax-categories.provider.js';
import { SecurityBusinessesProvider } from '../security-businesses.provider.js';
let pool: Pool;
// Both new tables carry a NOT NULL owner_id 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-0000000006f0';
const OTHER_OWNER_ID = '00000000-0000-0000-0000-0000000006f1';
const GENERAL_SECURITIES_BUSINESS_ID = '00000000-0000-0000-0000-0000000006f2';
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;
}
/**
* Only the fields ensureSecurityBusiness reads. The general foreign-securities business is a
* real row (seeded below) so the inheritance of sort code / IRS code / country is exercised
* rather than stubbed.
*/
function createMockAdminContextProvider(ownerId: string): AdminContextProvider {
return {
getVerifiedAdminContext: () =>
Promise.resolve({
ownerId,
foreignSecurities: {
foreignSecuritiesBusinessId: GENERAL_SECURITIES_BUSINESS_ID,
foreignSecuritiesFeesCategoryId: null,
},
}),
} as unknown as AdminContextProvider;
}
function createProvider(ownerId = TEST_OWNER_ID) {
const dbClient = new TenantAwareDBClient(
new DBProvider(pool),
createMockAuthContextProvider(ownerId),
);
const adminContextProvider = createMockAdminContextProvider(ownerId);
const businessesProvider = new BusinessesProvider(dbClient, adminContextProvider);
const taxCategoriesProvider = new TaxCategoriesProvider(dbClient, adminContextProvider);
const financialEntitiesProvider = new FinancialEntitiesProvider(
dbClient,
businessesProvider,
// BusinessesOperationProvider is only used by delete/replace paths this suite never takes.
{} as never,
taxCategoriesProvider,
);
return new SecurityBusinessesProvider(
dbClient,
adminContextProvider,
financialEntitiesProvider,
businessesProvider,
taxCategoriesProvider,
);
}
const APPLE = {
isin: 'US0378331005',
symbol: 'AAPL',
engName: 'APPLE INC',
hebName: 'אפל',
exchange: 'NYQ',
currencyCode: 'USD',
isForeign: true,
};
/**
* `financial_entities.owner_id` is an FK to `businesses`, so a tenant's own row must be
* self-owned (entity first with a null owner, then the business pointing at itself).
*/
async function seedBusiness(id: string, ownerId: string | null, name: string) {
await pool.query(
`INSERT INTO accounter_schema.financial_entities (id, name, type, owner_id)
VALUES ($1, $2, 'business', $3)
ON CONFLICT (id) DO NOTHING`,
[id, name, ownerId],
);
await pool.query(
`INSERT INTO accounter_schema.businesses (id, owner_id, country)
VALUES ($1, $2, 'ISR')
ON CONFLICT (id) DO NOTHING`,
[id, ownerId ?? id],
);
}
beforeAll(async () => {
pool = await connectTestDb();
await runMigrationsIfNeeded(pool);
for (const [index, ownerId] of [TEST_OWNER_ID, OTHER_OWNER_ID].entries()) {
await seedBusiness(ownerId, null, `security-businesses-test-owner-${index}`);
}
await seedBusiness(GENERAL_SECURITIES_BUSINESS_ID, TEST_OWNER_ID, 'Foreign Securities');
await ensureRlsRole(pool, {
grants: [
{ table: 'businesses_securities', privileges: 'SELECT' },
{ table: 'security_identifiers', privileges: 'SELECT' },
],
});
});
afterAll(async () => {
await dropRlsRole(pool);
// businesses_securities and security_identifiers cascade with the owning business.
for (const id of [GENERAL_SECURITIES_BUSINESS_ID, TEST_OWNER_ID, OTHER_OWNER_ID]) {
await pool.query('DELETE FROM accounter_schema.businesses WHERE owner_id = $1', [id]);
await pool.query('DELETE FROM accounter_schema.businesses WHERE id = $1', [id]);
await pool.query('DELETE FROM accounter_schema.financial_entities WHERE owner_id = $1', [id]);
await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = $1', [id]);
}
// Do NOT close the pool here — it is shared with other concurrently-running suites.
});
// DELETE rather than TRUNCATE CASCADE: TRUNCATE takes ACCESS EXCLUSIVE locks that deadlock
// against concurrently-running integration suites. Scoped to this suite's owners.
beforeEach(async () => {
const owners = [TEST_OWNER_ID, OTHER_OWNER_ID];
await pool.query(
`DELETE FROM accounter_schema.businesses
WHERE owner_id = ANY($1)
AND id IN (SELECT id FROM accounter_schema.businesses_securities)`,
[owners],
);
await pool.query(
`DELETE FROM accounter_schema.financial_entities
WHERE owner_id = ANY($1)
AND id NOT IN ($2, $3, $4)`,
[owners, TEST_OWNER_ID, OTHER_OWNER_ID, GENERAL_SECURITIES_BUSINESS_ID],
);
});
describe('ensureSecurityBusiness', () => {
it('creates a business named after the security and marks it as one', async () => {
const provider = createProvider();
const security = await provider.ensureSecurityBusiness(APPLE);
expect(security.isin).toBe(APPLE.isin);
expect(security.owner_id).toBe(TEST_OWNER_ID);
expect(security.symbol).toBe('AAPL');
const { rows } = await pool.query(
`SELECT fe.name, fe.type, b.hebrew_name, b.country
FROM accounter_schema.financial_entities fe
INNER JOIN accounter_schema.businesses b USING (id)
WHERE fe.id = $1`,
[security.id],
);
expect(rows[0]).toMatchObject({
name: 'APPLE INC (AAPL)',
type: 'business',
hebrew_name: 'אפל',
country: 'ISR',
});
});
it('normalizes the currency the feed reports into the closed type', async () => {
const provider = createProvider();
// The Poalim securities feed spells its currencies out in Hebrew.
const security = await provider.ensureSecurityBusiness({
...APPLE,
currencyCode: 'דולר ארה"ב',
});
expect(security.currency_code).toBe('USD');
});
it('leaves the currency empty rather than failing on a label it cannot resolve', async () => {
const provider = createProvider();
const security = await provider.ensureSecurityBusiness({
...APPLE,
currencyCode: 'ZZZ',
});
expect(security.currency_code).toBeNull();
expect(security.isin).toBe(APPLE.isin);
});
it('does not read a missing currency as the local one', async () => {
const provider = createProvider();
const security = await provider.ensureSecurityBusiness({ ...APPLE, currencyCode: ' ' });
expect(security.currency_code).toBeNull();
});
it('settles a whole payload in one lookup, creating only what is missing', async () => {
const provider = createProvider();
const first = await provider.ensureSecurityBusiness(APPLE);
const securities = await provider.ensureSecurityBusinesses([
APPLE,
{ isin: 'US5949181045', symbol: 'MSFT', engName: 'MICROSOFT CORP', isForeign: true },
]);
expect(securities.get(APPLE.isin)?.id).toBe(first.id);
expect(securities.get('US5949181045')?.symbol).toBe('MSFT');
const { rows } = await pool.query(
'SELECT count(*)::int AS count FROM accounter_schema.businesses_securities WHERE owner_id = $1',
[TEST_OWNER_ID],
);
expect(rows[0].count).toBe(2);
});
it('is idempotent per ISIN — a second call returns the same business', async () => {
const provider = createProvider();
const first = await provider.ensureSecurityBusiness(APPLE);
const second = await provider.ensureSecurityBusiness({ ...APPLE, engName: 'APPLE INCORPORATED' });
expect(second.id).toBe(first.id);
const { rows } = await pool.query(
'SELECT count(*)::int AS count FROM accounter_schema.businesses_securities WHERE owner_id = $1',
[TEST_OWNER_ID],
);
expect(rows[0].count).toBe(1);
});
/**
* The provider's queries carry no owner_id predicate — RLS is what scopes them. The test
* pool connects as postgres (BYPASSRLS), so asserting this 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 () => {
const security = await createProvider(TEST_OWNER_ID).ensureSecurityBusiness(APPLE);
await createProvider(TEST_OWNER_ID).linkIdentifier(
security.id,
'POALIM_SECURITY_KEY',
'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)`, [
OTHER_OWNER_ID,
]);
const rows = await runAsRlsRole(client, async () => {
const securities = await client.query(
'SELECT isin FROM accounter_schema.businesses_securities',
);
const identifiers = await client.query(
'SELECT identifier_value FROM accounter_schema.security_identifiers',
);
return [...securities.rows, ...identifiers.rows];
});
expect(rows).toEqual([]);
} finally {
await client.query('ROLLBACK');
client.release();
}
});
});
describe('linkIdentifier', () => {
it('resolves a Poalim security key to its security business', async () => {
const provider = createProvider();
const security = await provider.ensureSecurityBusiness(APPLE);
await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');
const found = await provider.getSecurityBusinessByIdentifierLoader.load({
ownerId: TEST_OWNER_ID,
type: 'POALIM_SECURITY_KEY',
value: '5129523',
});
expect(found?.id).toBe(security.id);
});
it('collapses several Poalim keys onto one ISIN, and is a no-op when repeated', async () => {
const provider = createProvider();
const security = await provider.ensureSecurityBusiness(APPLE);
await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');
await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');
await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '7654321');
const identifiers = await provider.getIdentifiersByBusinessIdLoader.load(security.id);
expect(identifiers.map(identifier => identifier.identifier_value)).toEqual([
'5129523',
'7654321',
]);
});
it('returns null for an unknown identifier', async () => {
const provider = createProvider();
const found = await provider.getSecurityBusinessByIdentifierLoader.load({
ownerId: TEST_OWNER_ID,
type: 'POALIM_SECURITY_KEY',
value: '0000001',
});
expect(found).toBeNull();
});
});