fix(mocks): abort like mp_abort, lock like mp_lockanytable, derive saslibs
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m58s
Build / Build-and-test-development (pull_request) Successful in 29m42s

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:
dc committed 2026-11-04 15:37:31 +00:00
1 parent 87fd46c825
commit ca602082ff
13 files changed
+575 -83

No files matched your search

+18 -3
View File
@@ -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
}
+157
View File
@@ -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 ────────────────────────────────────────────────────
/**
+14 -5
View File
@@ -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 -5
View File
@@ -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')
+35
View File
@@ -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: '' }])
})
+57 -3
View File
@@ -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
}
+122
View File
@@ -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')
})
+45 -40
View File
@@ -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', () => {
+16 -22
View File
@@ -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')
})