fix(mocks): abort like mp_abort, lock like mp_lockanytable, derive saslibs
Three more places where the mock answered differently from the SAS service. mp_abort (dcMockUtils): every mock refusal is now a sasjsAbort table (MSG + MAC) written where the macro writes it, so the client shows the abort modal and rejects the promise. A thrown error had become an HTTP 500 "Job execution failed" - the browser host discards the webout when a program throws - which is unreadable to the user and hides the message in a stack. mpAbort also stops the program, so nothing is written after a refusal; each service that can abort catches that stop at its top level. stagedata, loadfile, getauditfile and makedata now go through it, and the hand-rolled sasjsAbort blocks are gone. mp_lockanytable (dcMockUtils): a lock is a row in MPE_LOCKANYTABLE marked LOCKED carrying the holder's job id, user and reference, exactly as the macro records it, and a lock held by another session refuses the write with the macro's message. postdata and rejection now lock MPE_REVIEW and MPE_SUBMIT around each write, which is where postdata.sas and rejection.sas take them. The macro's retry loop is collapsed to a single attempt: the runtime is single threaded, so nothing can release a lock while the request waits. startupservice: saslibs is the distinct LIBREF of the permitted datasets, which is what "create table saslibs as select distinct libref from &syslast" produces - it was a four-column row describing the control library, with fields the client does not read. Tests: abort.test.js and locks.test.js join the harness suites; 25 pass, and 9 of the 13 relevant cases fail against the base services.
This commit is contained in:
1 parent
87fd46c825
commit
ca602082ff
13 files changed
+575
-83
No files matched your search
@@ -83,11 +83,13 @@ let brAction = brValue(brRows[0], 'ACTION')
|
||||
if (!brAction && typeof action !== 'undefined') brAction = action
|
||||
const ACTION = String(brAction || '').trim().toUpperCase()
|
||||
|
||||
try {
|
||||
|
||||
if (!ACTION) {
|
||||
throw new Error('ACTION is required: MAKEDATA, DIRLIST or WRITECHECK')
|
||||
mpAbort('ACTION is required: MAKEDATA, DIRLIST or WRITECHECK', _program)
|
||||
}
|
||||
if (['MAKEDATA', 'DIRLIST', 'WRITECHECK'].indexOf(ACTION) < 0) {
|
||||
throw new Error('ACTION must be MAKEDATA, DIRLIST or WRITECHECK')
|
||||
mpAbort('ACTION must be MAKEDATA, DIRLIST or WRITECHECK', _program)
|
||||
}
|
||||
|
||||
/** The deploy window closes once the settings file exists (see browse_gate). */
|
||||
@@ -255,7 +257,8 @@ function brWriteCheck(path) {
|
||||
return row
|
||||
}
|
||||
|
||||
switch (ACTION) {
|
||||
try {
|
||||
switch (ACTION) {
|
||||
case 'DIRLIST': {
|
||||
brBrowseGate()
|
||||
|
||||
@@ -6828,4 +6831,16 @@ _webout = `<h3>Data Controller Config</h3>
|
||||
<ol><li><a href="/AppStream/clickme">Launch Data Controller</a></li></ol>`
|
||||
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
// mpAbort already wrote and stopped the program; anything else is a refusal
|
||||
// or a failure of the seeding, which the SAS service reports the same way
|
||||
if (err && err.mockAbort) throw err
|
||||
mpAbort(err && err.message ? err.message : String(err), _program)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -13,6 +13,31 @@ const dataDir = dcConf.dataDir
|
||||
|
||||
const loadTableData = makeTableLoader(dataDir)
|
||||
|
||||
/**
|
||||
* mp_lockanytable, bound to this service's control library. rejection.sas
|
||||
* locks MPE_REVIEW and MPE_SUBMIT around its writes so two reviewers cannot
|
||||
* append to the same tables at once; the lock rows live in MPE_LOCKANYTABLE.
|
||||
*/
|
||||
function lockControl(ds, ref) {
|
||||
mpLockAnyTable('LOCK', {
|
||||
lib: dcConf.dcLibref,
|
||||
ds: ds,
|
||||
ref: ref,
|
||||
loadTable: (name) => loadTableData(name),
|
||||
saveTable: (name, data) => saveTableData(dataDir, name, data)
|
||||
})
|
||||
}
|
||||
|
||||
function unlockControl(ds) {
|
||||
mpLockAnyTable('UNLOCK', {
|
||||
lib: dcConf.dcLibref,
|
||||
ds: ds,
|
||||
loadTable: (name) => loadTableData(name),
|
||||
saveTable: (name, data) => saveTableData(dataDir, name, data)
|
||||
})
|
||||
}
|
||||
try {
|
||||
|
||||
// ─── Parse input (BrowserParams: TABLE, STP_REASON) ──────────────────────────
|
||||
|
||||
const _bpRow = fetchTable('BrowserParams')[0] || {}
|
||||
@@ -37,7 +62,9 @@ if (submitRow) {
|
||||
submitRow.NUM_OF_APPROVALS_REMAINING = 0
|
||||
submitRow.REVIEWED_BY_NM = userNm
|
||||
submitRow.REVIEWED_ON_DTTM = now
|
||||
lockControl('MPE_SUBMIT', tableId + ' rejection')
|
||||
saveTableData(dataDir, 'MPE_SUBMIT', submitData)
|
||||
unlockControl('MPE_SUBMIT')
|
||||
|
||||
const reviewData = loadTableData('MPE_REVIEW') || { rows: [] }
|
||||
reviewData.rows.push({
|
||||
@@ -48,7 +75,9 @@ if (submitRow) {
|
||||
REVIEWED_ON_DTTM: now,
|
||||
REVIEW_REASON_TXT: reason
|
||||
})
|
||||
lockControl('MPE_REVIEW', tableId + ' rejection')
|
||||
saveTableData(dataDir, 'MPE_REVIEW', reviewData)
|
||||
unlockControl('MPE_REVIEW')
|
||||
}
|
||||
|
||||
fromsas = [
|
||||
@@ -74,3 +103,9 @@ if (submitRow) {
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'fromsas', fromsas)
|
||||
webout('CLOSE')
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const mpeDataDir = dcConf.dataDir
|
||||
const stagingDir = nodePath.resolve(mpeDataDir, 'staging')
|
||||
const mpeLoadTableData = makeTableLoader(mpeDataDir)
|
||||
|
||||
try {
|
||||
|
||||
// ─── Parse input ──────────────────────────────────────────────────────────────
|
||||
// The submission id arrives as the `table` URL param, as in getauditfile.sas.
|
||||
|
||||
@@ -41,10 +43,9 @@ if (tableId) {
|
||||
|
||||
if (!tableId || !libds) {
|
||||
// getauditfile.sas aborts with the same message when either is missing.
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: 'Missing: libds table', MAC: 'getauditfile' }])
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'ERROR', DSID: '' }])
|
||||
webout('CLOSE')
|
||||
mpAbort('Missing: libds table', 'getauditfile', {
|
||||
tables: [['sasparams', [{ STATUS: 'ERROR', DSID: '' }]]]
|
||||
})
|
||||
} else {
|
||||
// The SAS service also checks EDIT or APPROVE access (mpe_accesscheck). The
|
||||
// mock seeds MPE_SECURITY but no mock service enforces it, so access is not
|
||||
@@ -102,3 +103,9 @@ if (!tableId || !libds) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -27,6 +27,30 @@ function saveTableToLib(tableName, tableData, tableLibref) {
|
||||
fs.writeFileSync(file, JSON.stringify(tableData, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* mp_lockanytable, bound to the control library. postdata.sas locks
|
||||
* MPE_REVIEW and MPE_SUBMIT around each write so two approvers cannot append
|
||||
* to the same tables at once; the lock rows live in MPE_LOCKANYTABLE.
|
||||
*/
|
||||
function lockControl(ds, ref) {
|
||||
mpLockAnyTable('LOCK', {
|
||||
lib: dcLibref,
|
||||
ds: ds,
|
||||
ref: ref,
|
||||
loadTable: (name) => mpeLoadTableData(name),
|
||||
saveTable: (name, data) => saveTableToLib(name, data)
|
||||
})
|
||||
}
|
||||
|
||||
function unlockControl(ds) {
|
||||
mpLockAnyTable('UNLOCK', {
|
||||
lib: dcLibref,
|
||||
ds: ds,
|
||||
loadTable: (name) => mpeLoadTableData(name),
|
||||
saveTable: (name, data) => saveTableToLib(name, data)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the staging subfolder for a given table_id, creating it if needed.
|
||||
* Each submission gets its own subfolder under staging/ so that staged data
|
||||
@@ -87,6 +111,7 @@ const _sctRow = fetchTable('SASControlTable')[0] || {}
|
||||
const action = _sctRow.ACTION || ''
|
||||
const loadRef = _sctRow.TABLE || _sctRow.table || ''
|
||||
|
||||
try {
|
||||
// ─── Load submission from MPE_SUBMIT ─────────────────────────────────────────
|
||||
|
||||
const submitData = mpeLoadTableData('MPE_SUBMIT') || { rows: [] }
|
||||
@@ -576,7 +601,9 @@ if (action === 'SHOW_DIFFS') {
|
||||
REVIEWED_ON_DTTM: sastime,
|
||||
REVIEW_REASON_TXT: 'APPROVAL ' + apprNo + ' of ' + numApprovalsRequired
|
||||
})
|
||||
lockControl('MPE_REVIEW', loadRef + ' Approval')
|
||||
saveTableToLib('MPE_REVIEW', reviewData)
|
||||
unlockControl('MPE_REVIEW')
|
||||
|
||||
// decrement remaining approvals in MPE_SUBMIT
|
||||
const subRow = submitData.rows.find((r) => r.TABLE_ID === loadRef)
|
||||
@@ -585,7 +612,9 @@ if (action === 'SHOW_DIFFS') {
|
||||
subRow.REVIEWED_BY_NM = userNm
|
||||
subRow.REVIEWED_ON_DTTM = sastime
|
||||
}
|
||||
lockControl('MPE_SUBMIT', loadRef + ' Approval')
|
||||
saveTableToLib('MPE_SUBMIT', submitData)
|
||||
unlockControl('MPE_SUBMIT')
|
||||
|
||||
// copy audit items from this session into the staging subfolder
|
||||
copySessionAuditItems(stageFolder, 'approve_' + apprNo)
|
||||
@@ -653,7 +682,9 @@ if (action === 'SHOW_DIFFS') {
|
||||
REVIEWED_ON_DTTM: sastime,
|
||||
REVIEW_REASON_TXT: 'APPROVAL ' + apprNo + ' of ' + numApprovalsRequired
|
||||
})
|
||||
lockControl('MPE_REVIEW', loadRef + ' Approval')
|
||||
saveTableToLib('MPE_REVIEW', reviewData)
|
||||
unlockControl('MPE_REVIEW')
|
||||
|
||||
// update MPE_SUBMIT to APPROVED
|
||||
const subRow = submitData.rows.find((r) => r.TABLE_ID === loadRef)
|
||||
@@ -663,7 +694,9 @@ if (action === 'SHOW_DIFFS') {
|
||||
subRow.REVIEWED_BY_NM = userNm
|
||||
subRow.REVIEWED_ON_DTTM = sastime
|
||||
}
|
||||
lockControl('MPE_SUBMIT', loadRef + ' Approval')
|
||||
saveTableToLib('MPE_SUBMIT', submitData)
|
||||
unlockControl('MPE_SUBMIT')
|
||||
|
||||
// record in MPE_DATALOADS (mirrors the dataloads entry in postdata.sas)
|
||||
const dataloadsData = mpeLoadTableData('MPE_DATALOADS') || { rows: [] }
|
||||
@@ -705,3 +738,9 @@ if (action === 'SHOW_DIFFS') {
|
||||
webout('OBJ', 'params', [])
|
||||
webout('CLOSE')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -818,6 +818,163 @@ function makeTableId() {
|
||||
return `DC${dtStr}_${rand6}_${pid4}`
|
||||
}
|
||||
|
||||
// ─── mp_abort.sas ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* mp_abort: refuse a request the way the SAS macro does.
|
||||
*
|
||||
* The response carries a sasjsAbort table (MSG + MAC), which the client turns
|
||||
* into the abort modal and a rejected promise. That is the difference between
|
||||
* a refusal a user can read and a generic HTTP 500 - or, on a route that expects
|
||||
* JSON, an "invalid Json string" in the adapter's log.
|
||||
*
|
||||
* Execution does not continue: the SAS macro is a soft abort, and the closest
|
||||
* JS equivalent is to stop the program. The body already written to the webout
|
||||
* is still returned - the browser host and the server both read the webout file
|
||||
* even when the program throws - so the abort response is what reaches the
|
||||
* client.
|
||||
*
|
||||
* @param {string} message the MSG the user sees
|
||||
* @param {string} mac the calling macro/service, for the log
|
||||
* @param {{tables?: Array<[string, Array]>}} [options] extra members to send
|
||||
* with the abort (eg the sasparams STATUS row a service emits beside it)
|
||||
*/
|
||||
function mpAbort(message, mac, options) {
|
||||
const opts = options || {}
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasjsAbort', [
|
||||
{
|
||||
MSG: String(message === undefined || message === null ? '' : message),
|
||||
MAC: String(mac === undefined || mac === null ? '' : mac)
|
||||
}
|
||||
])
|
||||
for (const table of opts.tables || []) {
|
||||
webout('OBJ', table[0], table[1])
|
||||
}
|
||||
webout('CLOSE')
|
||||
|
||||
const stop = new Error(String(message))
|
||||
stop.mockAbort = true
|
||||
throw stop
|
||||
}
|
||||
|
||||
// ─── mp_lockanytable.sas ─────────────────────────────────────────────────────
|
||||
|
||||
/** The SAS datetime form TX_FROM/TX_TO and LOCK_START_DTTM carry. */
|
||||
function sasDatetimeNow() {
|
||||
return Math.floor((Date.now() - SAS_EPOCH_MS) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's job id, as &sysjobid is. The browser host runs every program
|
||||
* with the same process id, so a per-run value is what distinguishes this
|
||||
* session's lock from another's.
|
||||
*/
|
||||
let _mockSysJobId = null
|
||||
|
||||
function mockSysJobId() {
|
||||
if (!_mockSysJobId) {
|
||||
const pid =
|
||||
typeof process !== 'undefined' && process.pid ? String(process.pid) : '0'
|
||||
_mockSysJobId = pid + String(Math.floor(Math.random() * 1e9))
|
||||
}
|
||||
return _mockSysJobId
|
||||
}
|
||||
|
||||
/**
|
||||
* mp_lockanytable: lock or unlock one table through MPE_LOCKANYTABLE.
|
||||
*
|
||||
* Same control table, same columns and the same failure message as the macro:
|
||||
* a lock is a row marked LOCKED carrying the holder's job id and reference, so
|
||||
* two sessions cannot write MPE_REVIEW / MPE_SUBMIT over each other.
|
||||
*
|
||||
* The macro retries (loops x loop_secs) because another session may release the
|
||||
* lock between attempts. Here nothing else can run while this request is inside
|
||||
* the loop - the runtime is single threaded - so the retries could only ever
|
||||
* fail, and the wait is skipped: a lock held elsewhere aborts at once with the
|
||||
* macro's message.
|
||||
*
|
||||
* @param {string} action LOCK or UNLOCK
|
||||
* @param {object} opts lib, ds, ref; loadTable(name), saveTable(name, data);
|
||||
* optional loops, pid, mac
|
||||
*/
|
||||
function mpLockAnyTable(action, opts) {
|
||||
const options = opts || {}
|
||||
const verb = String(action === undefined ? '' : action).toUpperCase()
|
||||
const lib = String(options.lib || '').toUpperCase()
|
||||
const ds = String(options.ds || '').toUpperCase()
|
||||
const ref = String(options.ref || '')
|
||||
const mac = options.mac || 'mp_lockanytable'
|
||||
const loops = Math.max(1, Number(options.loops || 25))
|
||||
const pid = String(options.pid || mockSysJobId())
|
||||
|
||||
if (!options.loadTable || !options.saveTable) {
|
||||
throw new Error('mp_lockanytable: no control table accessor was provided')
|
||||
}
|
||||
if (!ds) throw new Error('dataset was not provided')
|
||||
if (verb !== 'LOCK' && verb !== 'UNLOCK' && verb !== 'MAKETABLE') {
|
||||
throw new Error('Invalid action (' + action + ') provided')
|
||||
}
|
||||
|
||||
const ctl = options.loadTable('MPE_LOCKANYTABLE') || { rows: [] }
|
||||
ctl.rows = ctl.rows || []
|
||||
const row = ctl.rows.find(
|
||||
(entry) =>
|
||||
String(entry.LOCK_LIB || '').toUpperCase() === lib &&
|
||||
String(entry.LOCK_DS || '').toUpperCase() === ds
|
||||
)
|
||||
|
||||
if (verb === 'UNLOCK') {
|
||||
if (row && String(row.LOCK_PID || '') === pid) {
|
||||
row.LOCK_STATUS_CD = 'UNLOCKED'
|
||||
options.saveTable('MPE_LOCKANYTABLE', ctl)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (verb === 'MAKETABLE') return
|
||||
|
||||
if (!row) {
|
||||
ctl.rows.push({
|
||||
LOCK_LIB: lib,
|
||||
LOCK_DS: ds,
|
||||
LOCK_STATUS_CD: 'LOCKED',
|
||||
LOCK_START_DTTM: sasDatetimeNow(),
|
||||
LOCK_USER_NM: getDcUser(),
|
||||
LOCK_PID: pid,
|
||||
LOCK_REF: ref
|
||||
})
|
||||
options.saveTable('MPE_LOCKANYTABLE', ctl)
|
||||
return
|
||||
}
|
||||
|
||||
const heldByOther =
|
||||
String(row.LOCK_STATUS_CD || '').toUpperCase() === 'LOCKED' &&
|
||||
String(row.LOCK_PID || '') !== pid
|
||||
|
||||
for (let attempt = 1; heldByOther && attempt <= loops; attempt++) {
|
||||
/* nothing else can release it while this request runs - see above */
|
||||
if (attempt < loops) continue
|
||||
mpAbort(
|
||||
'Unable to lock ' +
|
||||
lib +
|
||||
'..' +
|
||||
ds +
|
||||
' via MPE_LOCKANYTABLE after ' +
|
||||
loops +
|
||||
' attempts.\nPlease ask your administrator to investigate!',
|
||||
mac
|
||||
)
|
||||
}
|
||||
|
||||
row.LOCK_STATUS_CD = 'LOCKED'
|
||||
row.LOCK_START_DTTM = sasDatetimeNow()
|
||||
row.LOCK_USER_NM = getDcUser()
|
||||
row.LOCK_PID = pid
|
||||
row.LOCK_REF = ref
|
||||
options.saveTable('MPE_LOCKANYTABLE', ctl)
|
||||
}
|
||||
|
||||
// ─── mp_filtergenerate.sas ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ const stagingDir = nodePath.resolve(mpeDataDir, 'staging')
|
||||
|
||||
const mpeLoadTableData = makeTableLoader(mpeDataDir)
|
||||
|
||||
try {
|
||||
|
||||
// ─── Parse input ──────────────────────────────────────────────────────────────
|
||||
// The file arrives as an uploaded fileref (_WEBIN_FILEREF1/2 - it may be either
|
||||
// position, so pick the one whose filename ends in .csv), and the target table
|
||||
@@ -246,11 +248,18 @@ if (!msg) {
|
||||
|
||||
// ─── Respond ─────────────────────────────────────────────────────────────────
|
||||
|
||||
webout('OPEN')
|
||||
if (msg) {
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: msg, MAC: 'loadfile' }])
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'ERROR', DSID: '' }])
|
||||
} else {
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }])
|
||||
mpAbort(msg, 'loadfile', {
|
||||
tables: [['sasparams', [{ STATUS: 'ERROR', DSID: '' }]]]
|
||||
})
|
||||
}
|
||||
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }])
|
||||
webout('CLOSE')
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -14,6 +14,7 @@ eval(
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
// ─── Parse input ──────────────────────────────────────────────────────────────
|
||||
|
||||
const _sctRow = fetchTable('SASControlTable')[0] || {}
|
||||
@@ -154,11 +155,19 @@ if (!hookErrmsg) stageSubmission({
|
||||
auditFiles: [{ name: origLibds + '.csv', content: toCsv(jsDataHeaders, jsDataRows) }]
|
||||
})
|
||||
|
||||
webout('OPEN')
|
||||
// stagedata.sas aborts when the pre-edit hook fails (msg=%str(hook error))
|
||||
if (hookErrmsg) {
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: hookErrmsg, MAC: 'stagedata' }])
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'ERROR', DSID: '' }])
|
||||
} else {
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }])
|
||||
mpAbort(hookErrmsg, 'stagedata', {
|
||||
tables: [['sasparams', [{ STATUS: 'ERROR', DSID: '' }]]]
|
||||
})
|
||||
}
|
||||
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasparams', [{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }])
|
||||
webout('CLOSE')
|
||||
|
||||
} catch (err) {
|
||||
// mpAbort has already written the abort response, which is what the client
|
||||
// reads; anything else is a real failure and belongs in the log
|
||||
if (!(err && err.mockAbort)) throw err
|
||||
}
|
||||
@@ -209,7 +209,18 @@ sasdatasets = sasdatasets.filter(
|
||||
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasdatasets', sasdatasets)
|
||||
webout('OBJ', 'saslibs', [{ LIBREF: dcLibref, LIBRARYNAME: 'Data Controller(' + dcLibref + ')', LIBRARYID: dcLibref, ENGINE: 'V9' }])
|
||||
// mirrors "create table saslibs as select distinct libref from &syslast" - the
|
||||
// libraries the permitted datasets live in, and nothing else
|
||||
const saslibs = []
|
||||
const seenLibs = {}
|
||||
for (const table of sasdatasets) {
|
||||
if (seenLibs[table.LIBREF]) continue
|
||||
seenLibs[table.LIBREF] = true
|
||||
saslibs.push({ LIBREF: table.LIBREF })
|
||||
}
|
||||
saslibs.sort((a, b) => a.LIBREF.localeCompare(b.LIBREF))
|
||||
|
||||
webout('OBJ', 'saslibs', saslibs)
|
||||
webout('OBJ', 'globvars', globvars)
|
||||
webout('ARR', 'xlmaps', xlmaps)
|
||||
webout('CLOSE')
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The abort contract, exercised through a service that refuses.
|
||||
*
|
||||
* mp_abort is how every SAS service refuses a request, and the mock has to
|
||||
* answer the same way: a sasjsAbort table the client turns into the abort modal
|
||||
* and a rejected promise. A thrown error becomes a bare HTTP 500 (or an
|
||||
* "invalid Json string" in the adapter's log), which is not something a user
|
||||
* can read.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { seededEstate, runService } = require('./harness')
|
||||
|
||||
test('a refusal is a sasjsAbort table, with the abort payload', () => {
|
||||
const estate = seededEstate()
|
||||
|
||||
// getauditfile.sas aborts when the table id does not resolve to a submission
|
||||
const body = JSON.parse(runService(estate, 'auditors/getauditfile'))
|
||||
|
||||
assert.deepEqual(body.sasjsAbort, [
|
||||
{ MSG: 'Missing: libds table', MAC: 'getauditfile' }
|
||||
])
|
||||
})
|
||||
|
||||
test('a service that emits a table beside the abort still emits it', () => {
|
||||
const estate = seededEstate()
|
||||
|
||||
// stagedata.sas sends sasparams STATUS=ERROR with the abort, and the screen
|
||||
// reads that row - so the abort must not replace it
|
||||
const body = JSON.parse(runService(estate, 'auditors/getauditfile'))
|
||||
assert.deepEqual(body.sasparams, [{ STATUS: 'ERROR', DSID: '' }])
|
||||
})
|
||||
@@ -14,9 +14,23 @@ const path = require('node:path')
|
||||
const SERVICES = path.join(__dirname, '..', 'sasjs', 'services')
|
||||
const APP_LOC = '/Public/app/dc'
|
||||
|
||||
// an estate is a few MB of seed data; a suite makes one per case, so they are
|
||||
// removed when the process ends rather than left to fill the temp filesystem
|
||||
const created = []
|
||||
process.on('exit', () => {
|
||||
for (const dir of created) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch (err) {
|
||||
/* a leftover temp dir is not a test failure */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** A minimal SASjs estate: a drive, every service, and a folder to browse. */
|
||||
function makeEstate() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mock-'))
|
||||
created.push(root)
|
||||
const drive = path.join(root, 'drive')
|
||||
const appDir = path.join(drive, 'files', ...APP_LOC.split('/').filter(Boolean))
|
||||
const servicesDir = path.join(appDir, 'services')
|
||||
@@ -102,7 +116,7 @@ function runService(
|
||||
'function webout(action, name, rows) {',
|
||||
" const cmd = String(action === undefined ? '' : action).toUpperCase();",
|
||||
" if (cmd === 'OPEN') { __wb = {}; return; }",
|
||||
" if (cmd === 'CLOSE') { _webout = JSON.stringify(__wb || {}); return; }",
|
||||
" if (cmd === 'CLOSE') { _webout = JSON.stringify(__wb || {}); fs.writeFileSync(weboutPath, _webout); return; }",
|
||||
" if (cmd !== 'OBJ' && cmd !== 'ARR') throw new Error('unknown webout action ' + action);",
|
||||
" if (!__wb) throw new Error(\"webout('\" + action + \"') requires webout('OPEN') first\");",
|
||||
" if (!name) throw new Error(\"webout('\" + action + \"') needs a member name\");",
|
||||
@@ -134,7 +148,20 @@ function runService(
|
||||
const fn = new Function('require', 'console', 'process', 'Buffer', 'fs', body)
|
||||
// the services log their progress; keep the test output readable
|
||||
const quiet = { log() {}, info() {}, warn() {}, error() {} }
|
||||
return fn(require, quiet, process, Buffer, fs)
|
||||
|
||||
let returned = ''
|
||||
try {
|
||||
returned = fn(require, quiet, process, Buffer, fs)
|
||||
} catch (err) {
|
||||
// mpAbort() writes its response and then stops the program, as the SAS soft
|
||||
// abort does; the runtime still returns the webout, so the harness must too
|
||||
if (!err || !err.mockAbort) throw err
|
||||
}
|
||||
|
||||
const stored = fs.existsSync(estate.weboutPath)
|
||||
? fs.readFileSync(estate.weboutPath, 'utf8')
|
||||
: ''
|
||||
return stored || returned
|
||||
}
|
||||
|
||||
/** Seeds the estate exactly as the deploy screen does, then returns it. */
|
||||
@@ -146,4 +173,31 @@ function seededEstate() {
|
||||
return estate
|
||||
}
|
||||
|
||||
module.exports = { makeEstate, seededEstate, runService, toCsv, APP_LOC }
|
||||
/** The file one control table lives in, on the estate's drive. */
|
||||
function tableFile(estate, name) {
|
||||
return path.join(estate.appDir, 'data', 'DC_JSLIB', name.toLowerCase() + '.json')
|
||||
}
|
||||
|
||||
/** The rows of a control table, as the drive stores them. */
|
||||
function readTable(estate, name) {
|
||||
const table = JSON.parse(fs.readFileSync(tableFile(estate, name), 'utf8'))
|
||||
return table.rows || []
|
||||
}
|
||||
|
||||
/** Rewrites one control table's rows, leaving its schema alone. */
|
||||
function writeTable(estate, name, rows) {
|
||||
const file = tableFile(estate, name)
|
||||
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
table.rows = rows
|
||||
fs.writeFileSync(file, JSON.stringify(table, null, 2))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
makeEstate,
|
||||
seededEstate,
|
||||
runService,
|
||||
toCsv,
|
||||
readTable,
|
||||
writeTable,
|
||||
APP_LOC
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* mp_lockanytable, exercised through the service that uses it.
|
||||
*
|
||||
* The SAS rejection/postdata services lock MPE_REVIEW and MPE_SUBMIT through
|
||||
* MPE_LOCKANYTABLE around each write, so two reviewers cannot append to the
|
||||
* same control tables at once. The mock wrote them straight through, leaving
|
||||
* the lock table empty and the guarantee unimplemented.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { seededEstate, runService, readTable, writeTable } = require('./harness')
|
||||
|
||||
const TABLE_ID = 'DC20261104-120000.000_000001_4242'
|
||||
|
||||
/** A submission waiting for review, and an empty lock table. */
|
||||
function withSubmission(estate) {
|
||||
writeTable(estate, 'MPE_SUBMIT', [
|
||||
{
|
||||
TABLE_ID: TABLE_ID,
|
||||
BASE_LIB: 'TESTDATA',
|
||||
BASE_DS: 'DEMO_01',
|
||||
SUBMIT_STATUS_CD: 'SUBMITTED',
|
||||
NUM_OF_APPROVALS_REQUIRED: 1,
|
||||
NUM_OF_APPROVALS_REMAINING: 1
|
||||
}
|
||||
])
|
||||
writeTable(estate, 'MPE_LOCKANYTABLE', [])
|
||||
}
|
||||
|
||||
function reject(estate) {
|
||||
return JSON.parse(
|
||||
runService(estate, 'approvers/rejection', {
|
||||
tableName: 'BrowserParams',
|
||||
rows: [{ TABLE: TABLE_ID, STP_REASON: 'values look wrong' }]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
test('a rejection locks and unlocks the tables it writes', () => {
|
||||
const estate = seededEstate()
|
||||
withSubmission(estate)
|
||||
|
||||
const body = reject(estate)
|
||||
|
||||
assert.equal(body.fromsas[0].RESPONSE, 'SUCCESS!')
|
||||
assert.equal(readTable(estate, 'MPE_SUBMIT')[0].SUBMIT_STATUS_CD, 'REJECTED')
|
||||
assert.equal(readTable(estate, 'MPE_REVIEW').length, 1)
|
||||
|
||||
const locks = readTable(estate, 'MPE_LOCKANYTABLE')
|
||||
assert.deepEqual(
|
||||
locks.map((lock) => lock.LOCK_LIB + '.' + lock.LOCK_DS).sort(),
|
||||
['DC_JSLIB.MPE_REVIEW', 'DC_JSLIB.MPE_SUBMIT'],
|
||||
'one lock row per table the service writes'
|
||||
)
|
||||
assert.ok(
|
||||
locks.every((lock) => lock.LOCK_STATUS_CD === 'UNLOCKED'),
|
||||
'left unlocked - the lock is released after each write'
|
||||
)
|
||||
assert.ok(
|
||||
locks.every((lock) => lock.LOCK_REF && lock.LOCK_USER_NM && lock.LOCK_PID),
|
||||
'the row says who took it and why, as the macro records it'
|
||||
)
|
||||
})
|
||||
|
||||
test('a lock held by another session refuses the write', () => {
|
||||
const estate = seededEstate()
|
||||
withSubmission(estate)
|
||||
writeTable(estate, 'MPE_LOCKANYTABLE', [
|
||||
{
|
||||
LOCK_LIB: 'DC_JSLIB',
|
||||
LOCK_DS: 'MPE_SUBMIT',
|
||||
LOCK_STATUS_CD: 'LOCKED',
|
||||
LOCK_PID: 'another-session',
|
||||
LOCK_USER_NM: 'someone-else',
|
||||
LOCK_REF: 'still approving'
|
||||
}
|
||||
])
|
||||
|
||||
const body = reject(estate)
|
||||
|
||||
assert.deepEqual(body.sasjsAbort, [
|
||||
{
|
||||
MSG:
|
||||
'Unable to lock DC_JSLIB..MPE_SUBMIT via MPE_LOCKANYTABLE after 25 ' +
|
||||
'attempts.\nPlease ask your administrator to investigate!',
|
||||
MAC: 'mp_lockanytable'
|
||||
}
|
||||
])
|
||||
assert.equal(
|
||||
readTable(estate, 'MPE_SUBMIT')[0].SUBMIT_STATUS_CD,
|
||||
'SUBMITTED',
|
||||
'the submission is untouched when the lock cannot be taken'
|
||||
)
|
||||
assert.equal(readTable(estate, 'MPE_REVIEW').length, 0)
|
||||
})
|
||||
|
||||
test('the lock is released only by the session that took it', () => {
|
||||
const estate = seededEstate()
|
||||
withSubmission(estate)
|
||||
writeTable(estate, 'MPE_LOCKANYTABLE', [
|
||||
{
|
||||
LOCK_LIB: 'DC_JSLIB',
|
||||
LOCK_DS: 'MPE_SUBMIT',
|
||||
LOCK_STATUS_CD: 'LOCKED',
|
||||
LOCK_PID: 'another-session',
|
||||
LOCK_USER_NM: 'someone-else',
|
||||
LOCK_REF: 'still approving'
|
||||
}
|
||||
])
|
||||
|
||||
// the refusal must not have taken the other session's lock away
|
||||
reject(estate)
|
||||
|
||||
const held = readTable(estate, 'MPE_LOCKANYTABLE').find(
|
||||
(lock) => lock.LOCK_DS === 'MPE_SUBMIT'
|
||||
)
|
||||
assert.equal(held.LOCK_STATUS_CD, 'LOCKED')
|
||||
assert.equal(held.LOCK_PID, 'another-session')
|
||||
})
|
||||
@@ -23,6 +23,17 @@ const runMakedata = (estate, options) =>
|
||||
|
||||
const parse = (text) => JSON.parse(text)
|
||||
|
||||
/**
|
||||
* A refused request: the response carries the sasjsAbort table mp_abort writes,
|
||||
* which is what the client turns into the abort modal.
|
||||
*/
|
||||
function abortOf(webout) {
|
||||
const body = parse(webout)
|
||||
assert.equal(body.sasjsAbort.length, 1, 'one abort row')
|
||||
assert.deepEqual(Object.keys(body.sasjsAbort[0]).sort(), ['MAC', 'MSG'])
|
||||
return body.sasjsAbort[0]
|
||||
}
|
||||
|
||||
test('a browse does not seed the control library', () => {
|
||||
const estate = makeEstate()
|
||||
|
||||
@@ -109,29 +120,15 @@ test('DIRLIST caps the listing and says it was truncated', () => {
|
||||
test('DIRLIST refuses a parent that is not an absolute path', () => {
|
||||
const estate = makeEstate()
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
for (const parent of ['relative/path', '/tmp/%sysfunc(system(id))']) {
|
||||
const abort = abortOf(
|
||||
runMakedata(estate, {
|
||||
rows: [
|
||||
{ ACTION: 'DIRLIST', PARENT: 'relative/path', DEPTH: 1, MAXOBS: 500 }
|
||||
]
|
||||
}),
|
||||
/Invalid parent/
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
runMakedata(estate, {
|
||||
rows: [
|
||||
{
|
||||
ACTION: 'DIRLIST',
|
||||
PARENT: '/tmp/%sysfunc(system(id))',
|
||||
DEPTH: 1,
|
||||
MAXOBS: 500
|
||||
}
|
||||
]
|
||||
}),
|
||||
/Invalid parent/
|
||||
)
|
||||
rows: [{ ACTION: 'DIRLIST', PARENT: parent, DEPTH: 1, MAXOBS: 500 }]
|
||||
})
|
||||
)
|
||||
assert.equal(abort.MSG, 'Invalid parent')
|
||||
assert.equal(abort.MAC, '/Public/app/dc/services/admin/makedata')
|
||||
}
|
||||
})
|
||||
|
||||
test('WRITECHECK reports a folder it can write to', () => {
|
||||
@@ -190,17 +187,17 @@ test('WRITECHECK reports a file as not a folder', () => {
|
||||
test('WRITECHECK refuses a missing or relative path', () => {
|
||||
const estate = makeEstate()
|
||||
|
||||
assert.throws(
|
||||
() => runMakedata(estate, { rows: [{ ACTION: 'WRITECHECK', PATH: '' }] }),
|
||||
/Missing path/
|
||||
const missing = abortOf(
|
||||
runMakedata(estate, { rows: [{ ACTION: 'WRITECHECK', PATH: '' }] })
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
runMakedata(estate, {
|
||||
rows: [{ ACTION: 'WRITECHECK', PATH: 'some/where' }]
|
||||
}),
|
||||
/Invalid path/
|
||||
assert.equal(missing.MSG, 'Missing path')
|
||||
|
||||
const relative = abortOf(
|
||||
runMakedata(estate, {
|
||||
rows: [{ ACTION: 'WRITECHECK', PATH: 'some/where' }]
|
||||
})
|
||||
)
|
||||
assert.equal(relative.MSG, 'Invalid path')
|
||||
})
|
||||
|
||||
test('the browse actions close once the settings file exists', () => {
|
||||
@@ -214,9 +211,10 @@ test('the browse actions close once the settings file exists', () => {
|
||||
[{ ACTION: 'DIRLIST', PARENT: '', DEPTH: 1, MAXOBS: 500 }],
|
||||
[{ ACTION: 'WRITECHECK', PATH: estate.pickme }]
|
||||
]) {
|
||||
assert.throws(
|
||||
() => runMakedata(estate, { rows }),
|
||||
/only available until DC is configured/
|
||||
const abort = abortOf(runMakedata(estate, { rows }))
|
||||
assert.equal(
|
||||
abort.MSG,
|
||||
'DIRLIST and WRITECHECK are only available until DC is configured'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -224,14 +222,21 @@ test('the browse actions close once the settings file exists', () => {
|
||||
test('ACTION is required and must be one of the three', () => {
|
||||
const estate = makeEstate()
|
||||
|
||||
assert.throws(
|
||||
() => runMakedata(estate, { rows: [{ PARENT: '' }] }),
|
||||
/ACTION is required/
|
||||
const missing = abortOf(runMakedata(estate, { rows: [{ PARENT: '' }] }))
|
||||
assert.equal(missing.MSG, 'ACTION is required: MAKEDATA, DIRLIST or WRITECHECK')
|
||||
|
||||
const unknown = abortOf(runMakedata(estate, { rows: [{ ACTION: 'SEED' }] }))
|
||||
assert.equal(
|
||||
unknown.MSG,
|
||||
'ACTION must be MAKEDATA, DIRLIST or WRITECHECK'
|
||||
)
|
||||
assert.throws(
|
||||
() => runMakedata(estate, { rows: [{ ACTION: 'SEED' }] }),
|
||||
/ACTION must be MAKEDATA, DIRLIST or WRITECHECK/
|
||||
|
||||
// the refusals must not have seeded anything: the program is what the deploy
|
||||
// screen polls for, and the settings file is what closes the deploy window
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(estate.appDir, 'services', 'admin', 'makedata.js'))
|
||||
)
|
||||
assert.ok(!fs.existsSync(path.join(estate.appDir, 'services', 'settings.js')))
|
||||
})
|
||||
|
||||
test('the CLI seed route works with its own table name', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { seededEstate, runService } = require('./harness')
|
||||
const { seededEstate, runService, readTable, writeTable } = require('./harness')
|
||||
|
||||
const DC_LIB = 'DC_JSLIB'
|
||||
|
||||
@@ -22,27 +22,8 @@ function startupservice(estate) {
|
||||
}
|
||||
|
||||
/** Rewrites one control table on the estate's drive. */
|
||||
function setTable(estate, name, rows) {
|
||||
const file = path.join(
|
||||
estate.appDir,
|
||||
'data',
|
||||
DC_LIB,
|
||||
name.toLowerCase() + '.json'
|
||||
)
|
||||
const table = JSON.parse(fs.readFileSync(file, { encoding: 'utf8' }))
|
||||
table.rows = rows
|
||||
fs.writeFileSync(file, JSON.stringify(table, null, 2))
|
||||
}
|
||||
|
||||
function getTable(estate, name) {
|
||||
const file = path.join(
|
||||
estate.appDir,
|
||||
'data',
|
||||
DC_LIB,
|
||||
name.toLowerCase() + '.json'
|
||||
)
|
||||
return JSON.parse(fs.readFileSync(file, { encoding: 'utf8' })).rows
|
||||
}
|
||||
const setTable = (estate, name, rows) => writeTable(estate, name, rows)
|
||||
const getTable = (estate, name) => readTable(estate, name)
|
||||
|
||||
/** The offset as SAS formats it, computed through a different Intl API. */
|
||||
function expectedOffset() {
|
||||
@@ -207,3 +188,16 @@ test('the session timezone and offset are read from the runtime', () => {
|
||||
'the TIMEZONE option is unset in this runtime, as it is on a Linux SAS'
|
||||
)
|
||||
})
|
||||
|
||||
test('saslibs is the distinct libraries of the permitted datasets', () => {
|
||||
const estate = seededEstate()
|
||||
const payload = startupservice(estate)
|
||||
|
||||
// "create table saslibs as select distinct libref from &syslast" - one row
|
||||
// per library the datasets live in, and no other columns
|
||||
const expected = [...new Set(payload.sasdatasets.map((t) => t.LIBREF))].sort(
|
||||
(a, b) => a.localeCompare(b)
|
||||
)
|
||||
assert.deepEqual(payload.saslibs, expected.map((libref) => ({ LIBREF: libref })))
|
||||
assert.ok(payload.saslibs.length > 1, 'the seed spans the control and test libs')
|
||||
})
|
||||
Reference in new issue
Block a user