`editors/restore` had no mock, and the reason it could not have had one is that `postdata` wrote no audit rows: an approval applied the load and left nothing to rewind. Both halves are here. postdata (final approval): writes MPE_AUDIT the way %mp_storediffs does - one row per column, keyed by the md5 of the business-key values joined by '|', with move_type A/D/M, is_pk on the key columns, is_diff on a changed one, and the old and new value on their own typed columns. An append carries every column on the new side, a delete every column on the old side (which is what lets a restore put the row back), and a modification the keys plus only the columns that actually changed. A failure writing the audit is logged and does not undo an approval that has already landed. restore: mpe_checkrestore's two decidable checks (the table has an audit table; the load has entries in it) with their messages, then mp_stripdiffs' window - the load's timestamp and every change to that table from then on, because a restore reverts this load *and all subsequent ones*. Per business key the pre-window state is the earliest event: an append means the row is staged for deletion (`_____DELETE__THIS__RECORD_____=YES`), a delete means it is staged with the values the delete recorded, and a modification means each column takes its earliest old value while untouched columns keep the current one. The result goes through the same stageSubmission the editors use, so it arrives as a new submission with the reason `Restoring <loadref>` and is approved like any other - restore.sas never writes the base table either. The access and group/RLS checks are not emulated, consistent with the rest of the mocks: nothing enforces MPE_SECURITY there. Tests: 52 pass across the seven mock suites, 7 of them new. They drive a real approve and then read the staged rows back: append staged for deletion, modification staged with the pre-load value, delete staged with the row's values, and restoring the first of two loads reverting the second as well. With the base postdata and no restore service, all 7 fail.
306 lines
8.7 KiB
JavaScript
306 lines
8.7 KiB
JavaScript
/**
|
|
* 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'
|
|
)
|
|
})
|