/** * The restore service, exercised through a real approve. * * restore.sas rewinds a load: it reads the diffs the approval recorded in the * audit table, takes that load's timestamp, and rebuilds the state the table * had before it - including every change made since (`mp_stripdiffs` reverts * "this change, plus ALL SUBSEQUENT CHANGES"). The result is staged as a new * submission for approval, never applied directly. * * Two gaps made that impossible on the mock estate: `postdata` wrote no audit * rows at all, and there was no restore service. These cases cover both. * * Run with: node --test sas/mocks/test/ */ const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const { seededEstate, runService, readTable, writeTable } = require('./harness') const BASE_LIB = 'TESTDATA' const BASE_DS = 'DEMO_01' const COLUMNS = [ 'ID', 'AMOUNT', 'SCORE', 'GRADE', 'REF', 'RATING', 'STATUS', 'LAST_REVIEWED' ] const DELETE_FLAG = '_____DELETE__THIS__RECORD_____' /** A submission waiting for its single approval. */ function stage(estate, tableId, rows) { const stageFolder = path.join( estate.appDir, 'data', 'DC_JSLIB', 'staging', tableId ) fs.mkdirSync(stageFolder, { recursive: true }) fs.writeFileSync( path.join(stageFolder, 'staged.json'), JSON.stringify({ table_id: tableId, libds: BASE_LIB + '.' + BASE_DS, libref: BASE_LIB, dsn: BASE_DS, submitted_by_nm: 'DEMO', submitted_on_dttm: Date.now() / 1000, headers: COLUMNS, rows }) ) // appended, not replaced: a later load on the same table must not erase the // submission row an earlier one still needs writeTable( estate, 'MPE_SUBMIT', readTable(estate, 'MPE_SUBMIT').concat([ { TABLE_ID: tableId, BASE_LIB: BASE_LIB, BASE_DS: BASE_DS, SUBMIT_STATUS_CD: 'SUBMITTED', NUM_OF_APPROVALS_REQUIRED: 1, NUM_OF_APPROVALS_REMAINING: 1 } ]) ) writeTable(estate, 'MPE_REVIEW', []) writeTable(estate, 'MPE_LOCKANYTABLE', []) } function approve(estate, tableId) { return JSON.parse( runService(estate, 'auditors/postdata', { tableName: 'SASControlTable', rows: [{ ACTION: 'APPROVE_TABLE', TABLE: tableId }] }) ) } const baseRows = (estate) => JSON.parse( fs.readFileSync(path.join(estate.appDir, 'data', BASE_LIB, 'demo_01.json'), 'utf8') ).rows /** Runs restore and returns the rows it staged for the new submission. */ function restore(estate, loadRef) { const body = JSON.parse( runService(estate, 'editors/restore', { tableName: 'restore_in', rows: [{ LOAD_REF: loadRef }] }) ) if (body.sasjsAbort) return body const newRef = body.restore_out[0].loadref const staged = JSON.parse( fs.readFileSync( path.join( estate.appDir, 'data', 'DC_JSLIB', 'staging', newRef, 'staged.json' ), 'utf8' ) ) return { newRef, rows: staged.rows, message: staged } } const rowWithId = (rows, id) => rows.filter((row) => Number(row.ID) === Number(id))[0] // ─── the audit trail ───────────────────────────────────────────────────────── test('an approval records the diffs in MPE_AUDIT', () => { const estate = seededEstate() stage(estate, 'DC-LOAD-1', [ { ID: 1, AMOUNT: 999, SCORE: 82, GRADE: '.a', REF: 1001, RATING: 4, STATUS: 1, LAST_REVIEWED: 23390 } ]) approve(estate, 'DC-LOAD-1') const audit = readTable(estate, 'MPE_AUDIT') assert.ok(audit.length > 0, 'the approval wrote audit rows') const amounts = audit.filter( (row) => row.TGTVAR_NM === 'AMOUNT' && row.MOVE_TYPE === 'M' ) assert.equal(amounts.length, 1) assert.equal(amounts[0].OLDVAL_NUM, 120, 'the value before the change') assert.equal(amounts[0].NEWVAL_NUM, 999) assert.equal(amounts[0].IS_DIFF, 1) assert.equal(String(amounts[0].LOAD_REF), 'DC-LOAD-1') // the business key travels on its own rows, which is how a key is rebuilt const keys = audit.filter((row) => Number(row.IS_PK) === 1) assert.ok(keys.some((row) => row.TGTVAR_NM === 'ID' && row.NEWVAL_NUM === 1)) assert.ok( keys.every((row) => /^[0-9A-F]{32}$/.test(String(row.KEY_HASH))), 'the key hash is md5 hex, as mp_storediffs writes it' ) }) // ─── restore ──────────────────────────────────────────────────────────────── test('restoring an append stages the row for deletion', () => { const estate = seededEstate() const appended = { ID: 99, AMOUNT: 100, SCORE: 50, GRADE: '.b', REF: 1002, RATING: 3, STATUS: 1, LAST_REVIEWED: 23390 } stage(estate, 'DC-LOAD-2', [appended]) approve(estate, 'DC-LOAD-2') assert.ok(rowWithId(baseRows(estate), 99), 'the append reached the base table') const restored = restore(estate, 'DC-LOAD-2') assert.equal(restored.rows.length, 1) assert.equal(restored.rows[0][DELETE_FLAG], 'YES') assert.equal(Number(restored.rows[0].ID), 99) assert.equal(restored.message.submitted_by_nm, 'DEMO') }) test('restoring a modification stages the values it had before the load', () => { const estate = seededEstate() const before = rowWithId(baseRows(estate), 1) stage(estate, 'DC-LOAD-3', [ { ID: 1, AMOUNT: 999, SCORE: before.SCORE, GRADE: before.GRADE, REF: before.REF, RATING: before.RATING, STATUS: before.STATUS, LAST_REVIEWED: before.LAST_REVIEWED } ]) approve(estate, 'DC-LOAD-3') assert.equal(Number(rowWithId(baseRows(estate), 1).AMOUNT), 999) const restored = restore(estate, 'DC-LOAD-3') assert.equal(restored.rows.length, 1) assert.equal(restored.rows[0][DELETE_FLAG], 'No') assert.equal(Number(restored.rows[0].ID), 1) assert.equal( Number(restored.rows[0].AMOUNT), Number(before.AMOUNT), 'the amount is the one the row had before the load' ) }) test('restoring a delete brings the row back with its values', () => { const estate = seededEstate() const before = rowWithId(baseRows(estate), 2) stage(estate, 'DC-LOAD-4', [ Object.assign({}, before, { [DELETE_FLAG]: 'YES' }) ]) approve(estate, 'DC-LOAD-4') assert.equal(rowWithId(baseRows(estate), 2), undefined, 'the row was deleted') const restored = restore(estate, 'DC-LOAD-4') assert.equal(restored.rows.length, 1) assert.equal(restored.rows[0][DELETE_FLAG], 'No') assert.equal(Number(restored.rows[0].ID), 2) assert.equal( Number(restored.rows[0].AMOUNT), Number(before.AMOUNT), 'the deleted row comes back as it was' ) }) test('restoring a load also reverts the changes made after it', () => { const estate = seededEstate() const before = rowWithId(baseRows(estate), 1) // two approvals touching the same row stage(estate, 'DC-LOAD-5', [ Object.assign({}, before, { AMOUNT: 200 }) ]) approve(estate, 'DC-LOAD-5') stage(estate, 'DC-LOAD-6', [ Object.assign({}, before, { AMOUNT: 300 }) ]) approve(estate, 'DC-LOAD-6') assert.equal(Number(rowWithId(baseRows(estate), 1).AMOUNT), 300) // restoring the FIRST load has to undo the second one too const restored = restore(estate, 'DC-LOAD-5') assert.equal(restored.rows.length, 1) assert.equal( Number(restored.rows[0].AMOUNT), Number(before.AMOUNT), 'the state before the first load, not the value the second one wrote' ) }) // ─── refusals ─────────────────────────────────────────────────────────────── test('restoring a load that is not in the audit table is refused', () => { const estate = seededEstate() // a submission for the table, so the audit table itself is known stage(estate, 'DC-LOAD-7', []) const body = restore(estate, 'DC-LOAD-7') assert.deepEqual(body.sasjsAbort, [ { MSG: 'Cannot restore because: No entry for DC-LOAD-7 in DC_JSLIB.MPE_AUDIT', MAC: 'restore' } ]) }) test('restoring a table with no audit table configured is refused', () => { const estate = seededEstate() stage(estate, 'DC-LOAD-8', []) writeTable( estate, 'MPE_TABLES', readTable(estate, 'MPE_TABLES').filter( (row) => !(row.libref === BASE_LIB && row.dsn === BASE_DS) ) ) const body = restore(estate, 'DC-LOAD-8') assert.equal(body.sasjsAbort.length, 1) assert.equal( body.sasjsAbort[0].MSG, 'Cannot restore because: TESTDATA.DEMO_01 has no audit table configured' ) })