Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
123 lines
13 KiB
JavaScript
123 lines
13 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { spawn, execFileSync } = require('node:child_process');
|
|
const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs');
|
|
const { tmpdir } = require('node:os');
|
|
const { join, resolve } = require('node:path');
|
|
const { once } = require('node:events');
|
|
const { createServer } = require('node:net');
|
|
const { randomUUID } = require('node:crypto');
|
|
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]) {
|
|
assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`);
|
|
}
|
|
const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort();
|
|
const previous = migrations(baseline), current = migrations(repo);
|
|
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');
|
|
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}`;
|
|
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{} 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){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);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close();
|
|
const beforeUsers=rows('User'), beforeSessions=rows('Session');
|
|
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();
|
|
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;});
|