Files
backend/test/bank-cards-durability.cjs
ksv741 0057925888
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
feat: add hourly portfolio sync history
2026-10-09 09:04:41 +03:00

220 lines
15 KiB
JavaScript

const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { once } = require('node:events');
const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs');
const { createServer } = require('node:net');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const repo = resolve(__dirname, '..');
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
const baseline = resolve(process.argv[2]);
assert.notEqual(baseline, repo, 'Baseline must be a separate historical build');
for (const root of [baseline, repo]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`);
}
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
const migrations = (root) => readdirSync(join(root, 'prisma/migrations')).filter((name) => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort();
const current = migrations(repo), previous = migrations(baseline);
assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations');
assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8'));
const Database = require(join(repo, 'node_modules/better-sqlite3'));
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
let file = join(dir, 'history.db');
const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
// Historical baseline config predates the Prisma RUST_LOG isolation.
delete env.RUST_LOG;
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
const rub = (value) => ({ currency: 'RUB', value });
async function start(root) {
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const port = server.address().port;
await new Promise((r) => server.close(r));
origin = `http://127.0.0.1:${port}`;
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
const log = openSync(join(dir, `app-${++run}.log`), 'w');
child = spawn(process.execPath, [join(root, 'dist/src/main.js')], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}`, PORT: String(port) }, stdio: ['ignore', log, log] });
closeSync(log);
for (let i = 0; i < 150; i++) {
if (child.exitCode !== null) throw new Error(`app exited ${child.exitCode}`);
try {
const r = await fetch(`${origin}/api/v1/auth/me`);
if (r.status === 401) return;
} catch { /* Startup connection failures are retried until the deadline. */ }
await new Promise((r) => setTimeout(r, 100));
}
throw new Error('app startup timeout');
}
async function stop() {
if (!child) return;
const c = child;
child = null;
if (c.exitCode !== null) return;
const ended = once(c, 'exit');
c.kill('SIGTERM');
await ended;
}
async function http(path, method = 'GET', body, token, status = 200) {
const r = await fetch(`${origin}/api/v1/${path}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
const text = await r.text();
assert.equal(r.status, status, `${method} ${path}: ${text}`);
return text ? JSON.parse(text) : null;
}
function migrate(root) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}` }, stdio: ['ignore', openSync(join(dir, `migration-${run}.log`), 'w'), 'pipe'] });
}
function rows(table) {
const db = new Database(file, { readonly: true });
db.defaultSafeIntegers(true);
try {
return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();
} finally {
db.close();
}
}
// The rollback build must leave every additive T-Bank dataset and work intent intact.
function seedTbank(owner) {
const db = new Database(file);
const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision;
const now = Date.now();
const scope = { userId: owner, revision, accountId: 'durable-account' };
const exact = { currency: 'RUB', value: '-9007199254740993.123456789' };
const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map((key) => `"${key}"`).join(',')}) VALUES (${Object.keys(row).map(() => '?').join(',')})`).run(...Object.values(row));
db.transaction(() => {
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner);
insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 });
insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now });
insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) });
insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now });
insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now });
insert('TbankInstrument', { provider: 'tbank', uid: 'durable-instrument', instrumentType: 'bond', metadata: JSON.stringify({ name: 'Synthetic' }), fetchedAt: now });
insert('TbankCatalogDiscovery', { provider: 'tbank', instrumentType: 'bond', query: 'SYN', instrumentUids: JSON.stringify(['durable-instrument']), fetchedAt: now });
insert('TbankInstrumentLookup', { provider: 'tbank', identifierType: 'ticker', identifier: 'SYN', matchedUids: JSON.stringify(['durable-instrument']), fetchedAt: now });
insert('TbankInstrumentSource', { provider: 'tbank', instrumentUid: 'durable-instrument', source: 'tbank', kind: 'coupons', rangeKey: 'durable', payload: JSON.stringify({ events: [] }), fetchedAt: now });
insert('TbankCandle', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', time: now, close: exact.value, payload: JSON.stringify({ close: exact.value }), isComplete: 1, fetchedAt: now });
insert('TbankCandleCoverage', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', from: now - 1000, to: now + 1000, isComplete: 1, fetchedAt: now });
insert('TbankSyncWork', { id: 'durable-work', userId: owner, revision, scopeKey: 'durable-intent', intent: JSON.stringify({ from: now - 1000, to: now + 1000 }), state: 'running', attemptId: 'stopped-attempt', processRunId: 'stopped-process', priority: 0, progress: JSON.stringify({ completed: 1 }), createdAt: now, updatedAt: now });
insert('TbankSyncStage', { workId: 'durable-work', attemptId: 'stopped-attempt', sourceKey: 'durable', itemKey: 'next-row', payload: JSON.stringify({ payment: exact }) });
})();
db.close();
}
function tbankSnapshot() {
const db = new Database(file, { readonly: true });
db.defaultSafeIntegers(true);
try {
assert.equal(db.pragma('integrity_check', { simple: true }), 'ok');
assert.deepEqual(db.pragma('foreign_key_check'), []);
return Object.fromEntries(db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND (name LIKE \'Tbank%\' OR name=\'_prisma_migrations\') ORDER BY name').all().map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()]));
} finally {
db.close();
}
}
(async () => {
try {
migrate(baseline);
await start(baseline);
const auth = await http('auth/register', 'POST', credentials);
const owner = auth.user.id;
await stop();
const db = new Database(file);
const columns = db.prepare('PRAGMA table_info("TbankConnection")').all().map(({ name }) => name);
const fields = ['userId', 'encryptedToken', 'connectedAt'];
const values = [owner, 'fictional-encrypted-token-never-used', new Date().toISOString()];
if (columns.includes('revision')) {
fields.push('revision');
values.push(randomUUID());
}
db.prepare(`INSERT INTO TbankConnection (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values);
await db.backup(join(dir, 'before-migration.db'));
db.close();
const beforeSessions = rows('Session'), beforeUsers = rows('User');
migrate(repo);
assert.deepEqual(rows('User'), beforeUsers);
assert.deepEqual(rows('Session'), beforeSessions);
const migratedDb = new Database(file, { readonly: true });
assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used');
migratedDb.close();
await start(repo);
let token = (await http('auth/login', 'POST', credentials)).accessToken;
const intent = { id: randomUUID(), name: 'Долговременная', openingDate: '2026-01-01', openingBalance: rub('1000') };
let card = await http('bank-cards', 'POST', intent, token, 201);
const category = (await http('bank-cards/categories', 'GET', null, token)).find((x) => x.isDefault);
await http(`bank-cards/categories/${category.id}`, 'PATCH', { name: 'Сохранённая категория', version: category.version }, token);
const expenseIntent = { id: randomUUID(), kind: 'expense', cardId: card.id, amount: rub('100'), date: '2026-08-01' };
let expense = await http('bank-cards/operations', 'POST', expenseIntent, token, 201);
const refundIntent = { id: randomUUID(), kind: 'refund', cardId: card.id, originalExpenseId: expense.id, amount: rub('100'), date: '2026-09-01' };
const refund = await http('bank-cards/operations', 'POST', refundIntent, token, 201);
const deletedIntent = { id: randomUUID(), kind: 'topup', cardId: card.id, amount: rub('12.34'), date: '2026-09-01' };
const deleted = await http('bank-cards/operations', 'POST', deletedIntent, token, 201);
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`, 'DELETE', null, token, 204);
card = await http(`bank-cards/${card.id}`, 'GET', null, token);
card = await http(`bank-cards/${card.id}`, 'PATCH', { version: card.version, archived: true }, token);
seedTbank(owner);
const tbankBefore = tbankSnapshot();
const snapshots = { cards: rows('BankCard'), operations: rows('BankCardOperation'), categories: rows('ExpenseCategory') };
await stop();
await start(repo);
token = (await http('auth/login', 'POST', credentials)).accessToken;
assert.deepEqual(await http('bank-cards', 'POST', intent, token, 201), card);
assert.deepEqual(await http('bank-cards/operations', 'POST', refundIntent, token, 201), refund);
await http('bank-cards/operations', 'POST', deletedIntent, token, 410);
assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card);
await stop();
assert.deepEqual(tbankSnapshot(), tbankBefore);
assert.deepEqual(rows('BankCard'), snapshots.cards);
assert.deepEqual(rows('BankCardOperation'), snapshots.operations);
assert.deepEqual(rows('ExpenseCategory'), snapshots.categories);
const latest = new Database(file);
await latest.backup(join(dir, 'after-history.db'));
latest.close();
await start(baseline);
token = (await http('auth/login', 'POST', credentials)).accessToken;
assert.equal((await http('auth/me', 'GET', null, token)).id, owner);
await stop();
assert.deepEqual(tbankSnapshot(), tbankBefore);
assert.deepEqual(rows('BankCard'), snapshots.cards);
assert.deepEqual(rows('BankCardOperation'), snapshots.operations);
assert.deepEqual(rows('ExpenseCategory'), snapshots.categories);
await start(repo);
token = (await http('auth/login', 'POST', credentials)).accessToken;
assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card);
await stop();
const restored = join(dir, 'restored.db');
const backup = new Database(join(dir, 'after-history.db'), { readonly: true });
await backup.backup(restored);
backup.close();
file = restored;
assert.deepEqual(tbankSnapshot(), tbankBefore);
assert.deepEqual(rows('BankCard'), snapshots.cards);
assert.deepEqual(rows('BankCardOperation'), snapshots.operations);
assert.deepEqual(rows('ExpenseCategory'), snapshots.categories);
assert.deepEqual(rows('User'), beforeUsers);
assert.ok(rows('Session').some((s) => s.id === beforeSessions[0].id));
assert.deepEqual(tbankSnapshot(), tbankBefore);
const restoredDb = new Database(file, { readonly: true });
assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used');
assert.equal(restoredDb.pragma('integrity_check', { simple: true }), 'ok');
restoredDb.close();
await start(repo);
token = (await http('auth/login', 'POST', credentials)).accessToken;
assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card);
await stop();
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
writeFileSync(join(dir, 'result.json'), JSON.stringify({ result: 'PASS', baseline, dir, checks: ['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore', 'foreign key and integrity checks after each boundary', 'baseline migration and auth', 'additive migration preserves User Session TbankConnection', 'restart preserves archive categories refund links tombstones fingerprints', 'same-intent replay after restart', 'previous application auth with additive schema', 'new application returns preserved history after rollback', 'SQLite backup and restored application reads history'] }, null, 2));
console.log(`PASS durability ${dir}`);
} finally {
await stop();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});