Merge pull request 'fix(mocks): getrawdata reads the library it was asked for, and aborts when it cannot' (#518) from fix/mock-getrawdata-resolution into release/7.23
Sites / deploy (push) Successful in 3m4s
Build / Build-and-ng-test (pull_request) Successful in 6m13s
Build / lighthouse (pull_request) Successful in 6m43s
Build / Build-and-test-development (pull_request) Successful in 23m34s

Reviewed-on: #518
This commit was merged in pull request #518.
This commit is contained in:
allan committed 2026-11-08 00:44:55 +00:00
commit c407f85000
4 files changed
+181 -24

No files matched your search

+57 -14
View File
@@ -27,6 +27,17 @@ function loadTableData(tableName) {
} catch(err) { return null }
}
/* The same, from the folder of any library on the Drive: makedata and
createdemodata write each library's tables under data/<libref>, and this
service downloads from the library it was asked for rather than from the
control one. */
function loadTableFrom(libref, tableName) {
try {
const file = nodePath.resolve(dataDir, '..', libref, tableName.toLowerCase() + '.json')
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
} catch(err) { return null }
}
// SAS epoch for date/datetime/time formatting
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
@@ -100,27 +111,45 @@ const DOWNLOAD_TYPES = {
helpers collide with it, so the envelope is written here the way mpAbort
writes it: the abort member, no CLOSE.
*/
function abortInput(message) {
function abortInput(message, mac) {
webout('OPEN')
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: _program + '.sas' }])
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
const stop = new Error(message)
stop.mockAbort = true
throw stop
}
const requestTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
const rawTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
const TABLE_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
/* %mpe_validatecol(...,LIBDS,...) strips a trailing -FC before validating, and
getrawdata.sas reads LIBREF.CAT-FC as a format catalog and then works with
the stripped pair (`table=cats("&lib..",ds)`), so the mock keeps ONE
canonical name from here on: the guard, the filename, the library it reads
and the name in an abort all use the stripped form rather than validating
one name and using another. */
const withoutCatalog =
requestTable.length > 3 && requestTable.slice(-3).toUpperCase() === '-FC'
? requestTable.slice(0, -3)
: requestTable
if (!requestTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
? rawTable.slice(0, -3)
: rawTable
if (!rawTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
const requestTable = withoutCatalog.toUpperCase()
const requestFilter = String(
typeof filter === 'undefined' || filter === null ? '' : filter
).trim() || '0'
/* ISINT is input(&filter,??best32.) with mod 1 = 0, and the estate's recorded
answers show the whole value has to read as a number: it rejects 1,000,
1abc and 1.5x, which a leading-numeric-prefix read would take, and accepts
7.0, 1e3, +5, 007 and -3. Number() alone is the wrong shape test - it reads
0x10 as 16, which the informat does not - so the number is matched first and
then asked whether it is whole. */
const FILTER_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/
const filterValue = Number(requestFilter)
if (!isFinite(filterValue) || Math.floor(filterValue) !== filterValue) {
if (
!FILTER_NUMBER.test(requestFilter) ||
!isFinite(filterValue) ||
Math.floor(filterValue) !== filterValue
) {
abortInput('Invalid filter')
}
@@ -130,22 +159,37 @@ const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WE
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
const filename = download ? requestTable.toUpperCase() + '.' + download.ext : ''
const filename = download ? requestTable + '.' + download.ext : ''
// Generate CSV from the table data (data-driven, not hardcoded)
let csvLines = []
// Determine the table name from the `table` param (format: LIBREF.TABLE)
const tableName = requestTable.split('.')[1]
const exportLibref = requestTable.split('.')[0]
/*
getrawdata.sas assigns the library it was asked for - %dc_assignlib(READ,&lib)
- and reads the data from there; the control library supplies only the
registration row. A table that cannot be resolved aborts before anything is
written, and the estate gives one message for both cases, a library it does
not have and a member missing from one it does:
NOLIB.MPE_TABLES -> Target table not found: NOLIB.MPE_TABLES
DC634301.NO_SUCH_TABLE -> Target table not found: DC634301.NO_SUCH_TABLE
The abort is mp_derivedvars', so the MAC is that macro's own default and not
the service, and the message carries the upcased LIBDS getrawdata.sas builds.
*/
const tableData = loadTableFrom(exportLibref, tableName)
if (!tableData || !tableData.columns || !tableData.rows) {
abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
}
const tableData = loadTableData(tableName)
if (tableData && tableData.columns && tableData.rows) {
/*
getrawdata.sas drops the registration's validity columns before it writes
the download (`data staged(drop=&txfrom &txto)`), so a control table never
carries its history columns into a user's file.
*/
const exportLibref = requestTable.split('.')[0]
const registry = loadTableData('MPE_TABLES')
const registration =
registry && registry.rows
@@ -214,7 +258,6 @@ if (tableData && tableData.columns && tableData.rows) {
}
csvLines.push(values.join(','))
}
}
_webout = csvLines.join('\n')
+76 -10
View File
@@ -209,11 +209,15 @@ test('getrawdata validates the table before it streams, as the estate does', ()
}
})
test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
test('getrawdata reads the filter the way the estate does', () => {
const estate = seededEstate({ demo: false })
const rejected = ['abc', '1.5', '1,000']
for (const filter of rejected) {
/* Recorded from the synthetic estate: each of these is answered "Invalid
filter" and nothing is written. 1,000 and 1abc are the interesting ones -
ISINT's input(&filter,??best32.) reading would take a leading numeric
prefix, but the estate rejects them, so the whole value has to read as a
number - and 0x10 is 16 to Number() and invalid there. */
for (const filter of ['abc', '1.5', '1,000', '1abc', '1.5x', '0x10']) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
})
@@ -225,20 +229,82 @@ test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
assert.equal(headersOf(estate), '', filter)
}
/* %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
so a decimal written as one and an exponent are both integers to it. The
estate's own answer for these three is a downstream abort (the filter RK
does not exist), not "Invalid filter", which is what shows validation
passed; the mock does not resolve filter RKs, so it streams. */
for (const filter of ['7.0', '1e3', ' 0 ']) {
/* And these pass validation. The estate's own answer for them is a downstream
abort (the filter RK does not exist), not "Invalid filter"; the mock does
not resolve filter RKs, so it streams. */
for (const filter of ['7.0', '1e3', '+5', '007', ' 7 ', '-3']) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
})
assert.doesNotMatch(body, /sasjsAbort/, filter)
assert.doesNotMatch(body, /Invalid filter/, filter)
assert.equal(headersOf(estate).split('\n')[0], 'Content-Type: application/csv', filter)
}
})
test('getrawdata resolves the catalog form to one canonical name', () => {
const estate = seededEstate({ demo: false })
/*
%mpe_validatecol(...,LIBDS,...) strips the -FC suffix before validating, and
getrawdata.sas then works with the stripped pair - so a request must not
validate one name and name the file after another. The estate answers a real
LIBREF.CAT-FC by running %mp_cntlout on the catalog; the mock has no catalog
support, so the body is the member's rows and its filename is the stripped
LIBREF.DS.
*/
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX-FC', filter: '0' }
})
assert.doesNotMatch(body, /sasjsAbort/)
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX.csv'
])
})
test('getrawdata downloads from the library it was asked for', () => {
/* DC_JSLIB is the control library; the data comes from the library named in
the LIBDS, which is what %dc_assignlib(READ,&lib) in getrawdata.sas does
and what createdemodata writes TESTDATA for. */
const estate = seededEstate()
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'TESTDATA.DEMO_ORDERS', filter: '0' }
})
assert.doesNotMatch(body, /sasjsAbort/)
assert.ok(body.split('\n').length > 2, 'the export holds rows of the requested library')
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=TESTDATA.DEMO_ORDERS.csv'
])
})
test('getrawdata aborts a table it cannot resolve, as the estate does', () => {
const estate = seededEstate({ demo: false })
const cases = [
// a library the estate does not have: recorded as Target table not found
['NOLIB.MPE_TABLES', 'Target table not found: NOLIB.MPE_TABLES'],
// a member missing from a library it does have: the same message
['DC_JSLIB.NO_SUCH_TABLE', 'Target table not found: DC_JSLIB.NO_SUCH_TABLE'],
// the message carries the upcased LIBDS getrawdata.sas builds
['nolib.mpe_tables', 'Target table not found: NOLIB.MPE_TABLES']
]
for (const [table, message] of cases) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table, filter: '0' }
})
// the abort is mp_derivedvars', so the MAC is that macro's default
assert.deepEqual(
JSON.parse(body).sasjsAbort,
[{ MSG: message, MAC: 'MP_ABORT' }],
table
)
assert.equal(headersOf(estate), '', table)
}
})
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
@@ -0,0 +1,24 @@
{
"_meta": {
"scenario": "missing_table",
"service": "public/getrawdata",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"sasjsAbort"
]
},
"body": {
"sasjsAbort": [
{
"MSG": "Target table not found: <LIBREF>.NO_SUCH_TABLE",
"MAC": "MP_ABORT"
}
],
"SYSCC": "0"
}
}
+24
View File
@@ -272,6 +272,30 @@
"note": "the download path: a table as the client saves it, CSV with a header row and the registration's validity columns dropped. Parsed to rows on both sides, because SAS quotes and pads each cell to the variable's width",
"accepted": []
},
{
"id": "missing_table",
"service": "public/getrawdata",
"params": {
"type": "CSV",
"table": "{{LIBREF}}.NO_SUCH_TABLE",
"filter": "0"
},
"mockParams": {
"type": "CSV",
"table": "DC_JSLIB.NO_SUCH_TABLE",
"filter": "0"
},
"members": [
"sasjsAbort"
],
"note": "a download for a table that cannot be resolved: the abort names the full LIBDS and comes from mp_derivedvars, so the MAC is the macro's default rather than the service. The same answer covers a library the estate does not have",
"accepted": [
{
"path": ".SYSCC",
"reason": "the SAS estate's wrapper adds SYSCC to an aborted response; a JS program's envelope is finalised at CLOSE, and the abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
}
]
},
{
"id": "control_library",
"service": "public/viewtables",