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();}} (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); 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(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(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(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)); 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:['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;});