Compare commits

...
Author SHA1 Message Date
dc bda5dea33e fix(mocks): catalogs are not datasets, and getcolvals validates shape before resolution
Build / Build-and-ng-test (pull_request) Successful in 3m10s
Build / lighthouse (pull_request) Successful in 6m50s
Build / Build-and-test-development (pull_request) Successful in 29m28s
Reviews 893 and 894 on #518.

894-1 and 894-2 - a -FC request named a format catalog, and the mock answered
it with the sibling dataset. Stripping the suffix for validation made the name
canonical but also changed which object was resolved, so LIBREF.NAME-FC returned
NAME's rows for a name that is not a catalog at all. Now the MPE_TABLES
registration decides: if it carries a row for NAME-FC the member is a catalog
and is streamed as LIBREF.NAME.ext; if it does not, the request is not a catalog
and the estate's own answer is reproduced - syscc=1012 from %mp_cntlout,
recorded as public/getrawdata/catalog_suffix_on_dataset. The catalog the fixture
does hold, MPE_X_CATALOG, has no format entries, so its listing is empty; that
is recorded in the parity README beside the other fixture divergences rather
than left as an unexplained empty body. The test that pinned the object swap now
asserts both halves.

894-3 - getcolvals answered a well-formed libref it does not have with "Invalid
libds", and a malformed one with "Target table not found": the two rules were
the wrong way round. getcolvals.sas validates the SHAPE first with
%mpe_validatecol(...,LIBDS,...) and that is the only "Invalid libds"; the table
is then left to %mp_derivedvars, which aborts "Target table not found: <LIBDS>"
with its own MAC. Both are now recorded: public/getcolvals/wrong_libref uses a
valid-length NOLIB and pins the resolution abort, and the new
public/getcolvals/malformed_libds pins the shape abort with an 11-character
libref. fault-paths.test.js went with them.

893-2 - loadTableFrom duplicated loadTableData, so the two could drift: one
loadTableFile(libref, tableName) now serves both, with the control library as
the default.

893-3 - an unreadable or malformed table file was swallowed into null and
surfaced as "Target table not found". It now throws naming the file, because a
broken fixture should not send the next reader looking for a missing table; an
absent member still returns null and keeps the recorded abort.

Tests: 149 pass. Each of the three behaviour changes was run against the
previous code first and fails there - swallowing parse errors, answering the
catalog with the dataset, and restoring the inverted getcolvals rule.
parity/check.js ok on all 25 scenarios.
2026-11-08 00:48:14 +00:00
dc ef7fccd5ab fix(mocks): one canonical table name, and the filter shape the estate enforces
Build / Build-and-ng-test (pull_request) Successful in 4m48s
Build / lighthouse (pull_request) Successful in 7m19s
Build / Build-and-test-development (pull_request) Successful in 23m43s
Reviews 890 and 892 on #517, both about getrawdata.js in this branch.

890 - the -FC strip fed only the shape guard. `withoutCatalog` was computed for
the LIBDS test and then dropped, so the filename, the table name and the library
were all taken from the unstripped value: a request the guard accepted resolved
nothing and came back as an empty download named LIBREF.DS-FC.ext.
getrawdata.sas strips the suffix and then works with the stripped pair
(`table=cats("&lib..",ds)`), so the mock does that too - `requestTable` is the
stripped, upcased name and everything after the guard uses it: the guard, the
filename, the library it reads and the name in an abort.

892 - `Number()` is not ISINT's rule. I checked the premise against the estate
rather than the macro reading, because the two disagree here: it answers
"Invalid filter" for 1,000 and 1abc (which a leading-prefix read would accept),
and for 0x10, which Number() reads as 16. What the estate does accept is
7.0, 1e3, +5, 007, " 7 " and -3, each of which then fails downstream on the
filter RK rather than on validation. The mock now matches the value with a
number pattern first and then asks whether it is whole, which reproduces all of
that; the test's claim is corrected to the recorded answers, and the comment no
longer says the reading was taken from the macro.

Tests: download-headers.test.js is 14 tests. Both new assertions go red against
the previous code - leaving the unstripped name downstream fails the catalog
test, and dropping the shape match fails the filter test on 0x10. Whole mock
suite: 147 tests, 147 pass; parity/check.js unchanged.
2026-11-08 00:03:55 +00:00
dc 39dfdf1230 fix(mocks): getrawdata reads the library it was asked for, and aborts when it cannot
Build / Build-and-ng-test (pull_request) Successful in 5m14s
Build / lighthouse (pull_request) Successful in 6m15s
Build / Build-and-test-development (pull_request) Canceled after 8m47s
Stacks on #517, which fixed the download headers of this service.

The mock loaded every table from the control library's folder, whatever LIBDS the
request named, and answered an unresolvable table with a 200 whose body was the
headers and nothing else. getrawdata.sas assigns the library it was given
(%dc_assignlib(READ,&lib)) and reads the data from there; the control library
supplies only the registration row that names the validity columns to drop. So:

- the table now loads from data/<LIBREF>/, which is where makedata and
  createdemodata write each library's files, and the LIBDS is upcased first as
  `call symputx('table',upcase(_table))` does
- a table that cannot be resolved aborts before anything is written, with the
  message and MAC the estate answers - recorded, not inferred:

    NOLIB.MPE_TABLES        -> Target table not found: NOLIB.MPE_TABLES   MP_ABORT
    DC634301.NO_SUCH_TABLE  -> Target table not found: DC634301.NO_SUCH_TABLE
    WORK.NOPE               -> Target table not found: WORK.NOPE

  The abort is mp_derivedvars', so the MAC is that macro's own default, and the
  message carries the upcased LIBDS rather than the service name.

Recorded: new parity scenario public/getrawdata/missing_table, golden taken from
the synthetic estate (one accepted difference, SYSCC, as for the getddl abort).

Tests: download-headers.test.js gains two - a download of TESTDATA.DEMO_ORDERS,
which lives outside the control library and used to answer an empty file, and
the three unresolvable-table cases above. Both go red against the old code:
loading from the control folder fails the first, and removing the abort fails
the second. Whole mock suite: 146 tests, 146 pass; parity/check.js ok on all 23
scenarios.
2026-11-07 23:49:45 +00:00
dc ad732f42b6 fix(mocks): the other two getrawdata aborts, and synchronous headers
Build / Build-and-ng-test (pull_request) Successful in 7m46s
Build / lighthouse (pull_request) Successful in 6m32s
Build / Build-and-test-development (pull_request) Successful in 29m16s
Review 886 on #517, both findings.

Finding 1 - getrawdata validates three inputs, and the mock only had the third.
getrawdata.sas aborts, in this order and all with mac=&_program..sas, before it
streams anything:

  table shape    -> "Invalid table"
  filter integer -> "Invalid filter"
  type list      -> "Invalid type"

The mock now reproduces all three, from the rules of the macros the service
calls: %mpe_validatecol(...,LIBDS,...) strips a trailing -FC and applies
%mp_validatecol's /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i, and
%mp_validatecol(...,ISINT,...) is input(??best32.) with mod 1 = 0, so 7.0 and
1e3 are integers and abc and 1.5 are not. Each answer was recorded from the
synthetic estate first:

  {table:'NOT_A_LIBDS'}                      -> Invalid table
  {filter:'abc'}                             -> Invalid filter
  no table at all                            -> Invalid table (was a
                                                ReferenceError before)
  {type:'FOOBAR',table:'NOT_A_LIBDS',filter:'abc'} -> Invalid table

Finding 2 - the tests waited 100ms for a fire-and-forget write. The cause was
the writers, not the tests: getddl, getrawdata and exportdb used the async
fs.writeFile for the headers file, while %mfs_httpheader (fopen/fput/fclose)
and dcMockUtils.mpStreamfile are synchronous. Those three now use
fs.writeFileSync, so the file is complete when the program returns; the waits
are gone, the positive assertions read the file directly, and the absence
assertions mean "never written" rather than "not written yet". No polling
remains in the file.

Also: mock-settings.test.js renamed the control library to DC7QATEST to prove
getrawdata follows the settings file - nine characters, which the estate
rejects as "Invalid table" for a LIBREF. The fixture is now DC7QATST. That was
a test defect the new validation exposed, not a product one.

Tests: download-headers.test.js is 11 tests, and the two new ones were run
against the old code first - dropping either check fails its own test. Whole
mock suite: 144 tests, 144 pass. parity/check.js unchanged.
2026-11-07 23:23:09 +00:00
dc bb771e9f6f fix(mocks): the download headers the estate actually sends
Build / Build-and-ng-test (pull_request) Successful in 4m13s
Build / lighthouse (pull_request) Successful in 6m6s
Build / Build-and-test-development (pull_request) Canceled after 9m7s
Finding 1 on #510 was that getddl sent an octet-stream, quoted-filename header
the estate never sends. The same defect was in every other download path, so
this fixes the class rather than the one service. Every value below was read
from the synthetic estate's own response, except where noted.

public/getrawdata, from getrawdata.sas and its recorded answers:

  SAS       application/text        LIBREF.DS.sas
  CSV       application/csv         LIBREF.DS.csv
  EXCEL     openxml sheet           LIBREF.DS.xlsx   (from the service source:
                                     PROC EXPORT xlsx fails on the synthetic
                                     estate with syscc=1012, so it cannot be
                                     recorded there)
  MARKDOWN  text/markdown           LIBREF.DS.md
  PDF       application/pdf         LIBREF.DS.pdf

- the filename is the upcased LIBREF.DS plus the type's extension, unquoted
- WEBCSV and WEBTAB send no headers at all: the estate writes those rows
  straight to _webout, and a content-disposition would advertise a download it
  does not offer
- a type outside that list aborts "Invalid type" with mac=&_program..sas before
  anything is streamed, which is what the estate answers for FOOBAR

admin/exportdb: %mp_streamfile(contenttype=TEXT, outname=&dc_libref..ddl), so
application/text and an unquoted <libref>.ddl - the mock sent text/plain.

The shared mpStreamfile helper in dcMockUtils (exportconfig, getauditfile) now
writes one header per line with the filename unquoted, as %mfs_httpheader does,
so the zip downloads stop quoting a name the estate does not quote.

Tests: sas/mocks/test/download-headers.test.js (renamed from
getddl-headers.test.js) now covers all four paths - 9 tests. Each of the new
assertions was run against the old behaviour first and fails there: text/csv
instead of application/csv, a quoted filename, and WEBCSV aborting instead of
streaming. Whole mock suite: 142 tests, 142 pass.
2026-11-07 23:09:31 +00:00
dc 719964f0af test(mocks): pin the getddl timestamp and header format
Build / Build-and-ng-test (pull_request) Successful in 3m36s
Build / lighthouse (pull_request) Successful in 6m2s
Build / Build-and-test-development (pull_request) Canceled after 25m5s
The parity gate masks the whole generated-by comment and carries no response
headers, so neither the local-time fix nor the header shape was visible to any
test. Both are now unit tests in their own file, which fixes TZ to a zone three
hours from UTC so a return to the UTC getters cannot pass by coincidence:

- the printed clock is the UTC clock plus the zone's offset, checked to within
  the two minutes a run can take
- the header file holds exactly the two lines %mfs_httpheader writes,
  application/text and an unquoted filename
- an aborted request still describes no download

Each was run against the pre-fix behaviour first: the UTC getters fail the
first test and the old single-line quoted header fails the second. Whole mock
suite: 136 tests, 136 pass.
2026-11-07 22:40:51 +00:00
dc 8ed51607a8 fix(mocks): answer getddl the way the estate does, headers and all
Build / Build-and-ng-test (pull_request) Successful in 3m54s
Build / lighthouse (pull_request) Successful in 8m1s
Build / Build-and-test-development (pull_request) Failing after 30m30s
Six findings from the review of #510, each checked against the synthetic
estate's own public/getddl response before being changed:

- the download is an application/text attachment, not octet-stream, and the
  filename is unquoted: the estate sends `content-type: application/text` and
  `content-disposition: attachment; filename=<libref>_<ds>.ddl`
- the DDL header timestamp is the session's local time, as %sysfunc(datetime(),
  datetime19.) is, not UTC - the estate answers 07NOV2025:22:27 where UTC
  getters give 21:27, and the projection masks the comment, so nothing else
  could see the drift
- the headers are written after the DDL is generated, as %mp_streamfile runs
  after %mp_ds2ddl, so nothing is sent before a request that cannot produce a
  file has aborted
- the refusal of an unknown flavour was invented: getddl.sas passes it to
  mp_ds2ddl, which has branches for SAS, TSQL and PGSQL only and leaves the
  file holding its opening comment. The estate answers that as a 200 download,
  and the mock now does too; a new parity scenario, unknown_flavour, pins it
- the "all forty control tables" claim in the DDL comment overstated what the
  committed goldens show: MPE_TABLES is the table recorded, in three flavours
- getcols: drop the dead `libref = knownLib` assignment

Tests: node --test sas/mocks/test/*.test.js - 133 pass, 0 fail, and
parity/check.js reports 0 differences for every getddl scenario.
2026-11-07 21:29:59 +00:00
allan 26a8818f26 Merge pull request 'feat(mocks): give every control table its key, and generate the DDL download' (#510) from feat/mock-getddl-parity into release/7.23
Sites / deploy (push) Successful in 3m49s
Build / Build-and-ng-test (pull_request) Successful in 4m46s
Build / lighthouse (pull_request) Successful in 7m30s
Build / Build-and-test-development (pull_request) Successful in 23m50s
Reviewed-on: #510
2026-11-07 21:24:29 +00:00
dc d4335e83c6 docs(mocks): the getcols libref work is in this branch now
Build / Build-and-ng-test (pull_request) Successful in 3m46s
Build / lighthouse (pull_request) Successful in 6m44s
Build / Build-and-test-development (pull_request) Successful in 24m21s
The README still listed a LIBDS naming a library the estate does not have as
outstanding work - the mock falls back to searching every libref and answers.
That is done: the library is validated first and the fallback is gone, and the
entry would have contradicted the code whichever of the two pull requests landed
second.

Folds in #515 (the two getcols aborts, the NOLIB scenario and its unit test) so
the mock parity work is one branch rather than one per finding.
2026-11-07 20:47:14 +00:00
dc 4be34a760e fix(mocks): tell a malformed LIBDS from one getcols cannot resolve
getcols.sas answers two different things and the previous commit collapsed them.
"Invalid libds" comes from the shape check (%mp_validatecol - a libref of eight
characters or fewer, a member of 32 or fewer), and only from there. A LIBDS that
passes the shape and cannot be resolved reaches the second abort, msg=syscc=&syscc.

Probed on the synthetic estate, that second abort says syscc=3000 with a SAS
syntax error behind it for a well-formed unknown library, for a missing member in
a known library, and for WORK.NO_SUCH alike - a defect in getcols.sas rather than
a designed message. The mock reproduces it as it is, because that is what the
client renders in production today, and the comment says so rather than
pretending it is designed.

The scenario is re-recorded with NOLIB.MPE_TABLES, which passes the shape check;
the old value, NO_SUCH_LIB, is eleven characters and only ever exercised the
shape abort, so its golden proved less than its name claimed. The unit test now
covers the malformed, the unresolvable and the answerable cases separately.
2026-11-07 20:46:42 +00:00
dc b0bb57ecad test(mocks): tie each table's key to the columns it names
Build / Build-and-ng-test (pull_request) Canceled after 2m36s
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / lighthouse (pull_request) Canceled after 0s
The index map and the schema are two lists of the same column names, and nothing
else joins them: a later rename in one would leave getddl emitting CREATE UNIQUE
INDEX on a column the table does not have, while the test that exists to check
every table has a key kept passing.

Each uniqueIndex column must now resolve to a column of its own table,
case-insensitively, so a rename on either side fails here instead of in a
download.

Merges release/7.23, which carries the 4.14.0 pinned estate and the lazy-route
revert.
2026-11-07 20:37:51 +00:00
allan 08fe488ee2 Merge branch 'release/7.23' into feat/mock-getddl-parity
Build / Build-and-ng-test (pull_request) Successful in 4m15s
Build / lighthouse (pull_request) Successful in 7m21s
Build / Build-and-test-development (pull_request) Canceled after 13m46s
2026-11-07 20:17:38 +00:00
allan d90ad6b944 Merge pull request 'chore(ci): run the mocked estate on 4.14.0' (#509) from chore/server-4.14.0 into release/7.23
Sites / deploy (push) Successful in 3m8s
Build / Build-and-ng-test (pull_request) Successful in 3m51s
Build / lighthouse (pull_request) Successful in 7m26s
Build / Build-and-test-development (pull_request) Failing after 27m57s
Reviewed-on: #509
2026-11-07 20:17:18 +00:00
dc a3f76b45b2 Merge release/7.23 into the mock parity branch
Build / Build-and-ng-test (pull_request) Canceled after 1m0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / lighthouse (pull_request) Canceled after 0s
# Conflicts:
#	sas/mocks/test/harness.js
#	sas/mocks/test/parity/scenarios.json
2026-11-07 20:16:09 +00:00
dc 0cfcd7dcc8 fix(mocks): send the payload keys the client matches, and stop the flake
Build / Build-and-ng-test (pull_request) Successful in 3m50s
Build / lighthouse (pull_request) Successful in 6m6s
Build / Build-and-test-development (pull_request) Canceled after 7m41s
Review findings on this pull request, and one CI failure they predicted.

The CI failure was the case change, not the DDL: getdata's DTVARS, DTTMVARS and
TMVARS still listed col.name, so they read " some_date" while every header and
row key was "SOME_DATE", and the client matches those lists against the headers
case-sensitively - an Excel upload stopped treating those columns as dates
(the Cypress failure was '21958' where '1960-02-12' was expected, across several
specs). They go through payloadKey now, like every other name in the payload.

Also from the review: the payloadKey doc block had displaced mpGetcols' JSDoc,
so mpGetcols was undocumented and payloadKey was described as taking a table's
columns; it sits above the block again. ddlDialectColumn's unreachable null
return is gone, and an unrecognised flavour aborts rather than quietly
answering SAS DDL under a label it was not asked for.

The intermittent suite failure the reviewer saw is the harness pruning a
session directory under a pending async header write; #512 carries the same
fix, and this branch needs it too or the gate fails at random.

getrawdata's CSV header still upcases inline: that service carries its own
helpers rather than loading dcMockUtils, so payloadKey is not in its scope.
2026-11-07 19:48:19 +00:00
dc 1f1ebc6aa4 chore(ci): merge release/7.23 into the 4.14.0 pin branch
Build / Build-and-ng-test (pull_request) Successful in 3m51s
Build / lighthouse (pull_request) Successful in 6m33s
Build / Build-and-test-development (pull_request) Successful in 27m52s
2026-11-07 19:27:49 +00:00
dc 98c93127e2 feat(mocks): generate the TSQL and PGSQL flavours of the DDL download too
Build / Build-and-ng-test (pull_request) Successful in 3m29s
Build / lighthouse (pull_request) Successful in 6m27s
Build / Build-and-test-development (pull_request) Failing after 40m7s
public/getddl answered the SAS text for both remaining flavours, which the
download menu labels by name. Each is generated from the same table metadata
now, and each was checked against the estate's own output before it went in:
all forty control tables, byte for byte, in all three dialects.

The dialects differ in more than type names. Postgres quotes with double quotes
and declares its key as a second statement after the table; TSQL brackets the
identifiers, carries the key inside the create table as a UNIQUE NONCLUSTERED
index, and turns every column LABEL into a sys.sp_addextendedproperty block
after the GO. Only a format whose name starts with DATETIME becomes a timestamp
in either - a DATE9. or E8601DT26.6 column stays DOUBLE PRECISION or
decimal(18,2), which is what the estate does and is not what a format list would
suggest.

Recorded as public/getddl/mpe_tables_pgsql and public/getddl/mpe_tables_tsql,
so each dialect is a gate rather than something re-checked by hand.
2026-11-07 18:19:00 +00:00
allan dd3a8b2f83 Merge branch 'release/7.23' into chore/server-4.14.0
Build / Build-and-ng-test (pull_request) Successful in 3m27s
Build / lighthouse (pull_request) Successful in 6m19s
Build / Build-and-test-development (pull_request) Failing after 48m57s
2026-11-07 18:16:50 +00:00
dc 96f70d53b6 feat(mocks): generate the DDL download from the table, not a sample
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Build / Build-and-test-development (pull_request) Canceled after 3m24s
Build / lighthouse (pull_request) Canceled after 3m22s
public/getddl answered one fixed Postgres-flavoured body for every request -
DC_JSLIB.MPE_X_TEST whatever dataset was asked for - which is the last read path
the client calls where the mock invented its answer.

It now builds the SAS-flavour DDL from the table file, the way mp_ds2ddl builds
it from dictionary.columns and dictionary.indexes: the header, the create table
with each column's type, format, NOT NULL and label, and the unique index. The
list-output spacing was derived from the estate's own answers and reproduces
them byte for byte for all forty control tables.

Recorded as public/getddl/mpe_tables, so a mock change that alters the text
fails the gate. The header credits a user and a timestamp, so the projection
masks both - a user id is not something to publish, and the comparison is about
the DDL.

TSQL and PGSQL are not generated yet: a caller asking for one gets the SAS text,
and the README says so rather than inventing a second dialect without a
recording to check it against.
2026-11-07 18:10:11 +00:00
dc d189fd4055 feat(mocks): give every control table the key and column case the estate has
A control table on a real estate is created with a unique index - MPE_TABLES on
tx_to/libref/dsn, MPE_X_TEST on primary_key_field, and so on - and mp_ds2ddl
turns that into the CREATE UNIQUE INDEX block of a DDL download. The mock's
tables had columns and rows and no key at all, which is why getddl could only
ever answer a fixed sample.

Each table file now carries `uniqueIndex` (name plus the ordered columns), taken
from the synthetic estate's own DDL: 39 of the 40 control tables have one,
MPE_REQUESTS is the exception the estate also has, and the format catalog marker
has none because it is not a dataset.

The columns also move to the case SAS creates them with. A table declared with
`alert_event` keeps that spelling in dictionary.columns and in the DDL, while
every payload the client reads reports the name upper case. The files now store
the creation case and the services build their keys through payloadKey(), which
fixes the DDL and leaves the payloads alone.

Two metadata gaps went with them: MPE_LOADS.csv_dir and MPE_MAXKEYVALUES.keytable
are NOT NULL on the estate and were not here.
EOF2
echo "--- second commit ---"; git log --oneline -1 | cat
2026-11-07 18:09:50 +00:00
dc 5aa8cd75fc docs(ci): correct the pin rationale after review
Build / Build-and-ng-test (pull_request) Successful in 4m22s
Build / lighthouse (pull_request) Successful in 7m19s
Build / Build-and-test-development (pull_request) Failing after 38m15s
Three claims in the comment block did not survive checking: the 3.14.0
paragraph described the utils floor as still open when 4.14.0 is the release
that raises it, the 3.12.3 paragraph named a guard that does not exist, and
the 4.0.0 paragraph was incomplete.

The logout one is the interesting correction: the estate does call
GET /SASLogon/logout - @sasjs/adapter's logOut() is a GET on that route (client
sas.service.ts logout() -> sasjsAdapter.logOut() -> AuthManager.logOut()), so
the paragraph now says what the estate does touch and why the status change
does not matter to it.
2026-11-07 17:29:01 +00:00
dc 8e2e407837 chore(ci): run the mocked estate on 4.14.0
Build / Build-and-ng-test (pull_request) Successful in 3m23s
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / lighthouse (pull_request) Canceled after 0s
4.14.0 is the first @sasjs/server release that compresses its responses and
sends cache headers for the app it streams (sasjs/server#575), which is what
the audit has been measuring: the app is a 4.83MB build output, and the
previous pin served it raw and uncacheable.

Verified on the published package before pinning it here: 4.14.0 boots, the
mock estate deploys and seeds against it, and a deployed asset now answers

  Content-Encoding: gzip
  Cache-Control: public, max-age=31536000, immutable

with index.html answering max-age=0, must-revalidate. Measured on the previous
pin, same build and machine: FCP 8.48s and 0.34 performance raw, 2.81s and
0.49 with the same bytes compressed.

4.0.0's breaking change is the auth statuses (401 where 403 was, and the
undocumented DELETE on logout is gone). The client treats any non-2xx as a
refusal; the suite running against this pin is the check.

sites.yaml and release.yaml move with it - same mocked estate, one version.
2026-11-07 17:01:11 +00:00
31 changed files with 1747 additions and 543 deletions

No files matched your search

+29 -8
View File
@@ -15,16 +15,37 @@ concurrency:
env:
# the server the mocked services and app tests run against - bump deliberately.
#
# 4.14.0 is the pin because it is the first release that compresses its
# responses and sends cache headers for the app it streams (sasjs/server#575).
# That is not cosmetic here: the audit runs against this server, and the app it
# serves is a single 4.83MB build output. Measured on the previous pin, same
# build and same machine: FCP 8.48s and 0.34 performance with the assets served
# raw, 2.81s and 0.49 with the same bytes gzipped and hashed files immutable.
#
# 4.0.0's breaking change is the auth statuses - `POST /SASjsApi/auth/token` and
# `/auth/refresh` answer 401 where they answered 403, the undocumented DELETE on
# the logout route is gone, and `GET /SASLogon/logout` answers 500 where it
# answered 403 when the session store fails. The estate does touch all three,
# through @sasjs/adapter: auth/AuthManager.js logs out with a GET on the logout
# route and auth/getAccessTokenForSasjs.js posts the token. What it keys on is
# the call succeeding rather than the code being 403 in particular, so 403 to
# 401 changes nothing here - and the suite on this pin checks the logout path
# still completes.
#
# The floors this pin also carries, kept because a future bump has to keep
# clearing them:
#
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which
# client/cypress/e2e/va-ddc-host.cy.ts needs. 3.12.2 also carries the
# packaged-executable fix for the same page, so the version the tests run
# against is the one that ships.
#
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands) and is the
# release the estate runs. It still declares @sasjs/utils ^4.7.0, so the
# envelope *formats* (SYSCC as text, DATE7, TIME5, E8601DT26.6 from utils 4.8)
# are only guaranteed once the next server release raises that floor - a fresh
# install resolves 4.8.0 today, a cached tree need not. Bump again when it lands.
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands). It declared
# @sasjs/utils ^4.7.0, which is why the envelope *formats* (SYSCC as text,
# DATE7, TIME5, E8601DT26.6) used to depend on what a fresh install resolved:
# 4.8 introduced them, a 4.7 tree did not have them. 4.14.0 declares ^4.8.0,
# so the pin guarantees them now rather than being a floor that hoped for them.
#
# 3.13.0 is the one that matters for licensing: @sasjs/utils 4.7 reports
# SYSSITE, which %webout takes from &syssite. Before it the prelude wrote an
@@ -36,9 +57,9 @@ env:
# options argument. getdata, viewdata and getcolvals pass { showmeta }, and a
# 4.5 install drops it without a word - so the pin is what makes the metadata
# members guaranteed here rather than whatever a fresh install happens to
# resolve. requireWeboutOptions in sasjs/mocks/sasjs/services/dcMockUtils.js
# refuses a runtime without it.
SASJS_SERVER_VERSION: '3.14.0'
# resolve. sas/mocks/test/harness-webout.test.js is what holds that: showmeta
# is written beside the rows, before CLOSE.
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
jobs:
+3 -1
View File
@@ -7,7 +7,9 @@ on:
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.14.0'
# (build.yaml carries the full rationale; this workflow runs the same mocked
# estate, so it stays on the same pin).
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
jobs:
+3 -1
View File
@@ -11,7 +11,9 @@ on:
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.14.0'
# (build.yaml carries the full rationale; this workflow runs the same mocked
# estate, so it stays on the same pin).
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
+7 -5
View File
@@ -148,13 +148,15 @@ for (const table of tables) {
// ─── Stream it back as a download, as mp_streamfile does ─────────────────────
/* exportdb.sas streams with %mp_streamfile(contenttype=TEXT
,outname=&dc_libref..ddl): an application/text attachment named
<libref>.ddl, unquoted. Confirmed against the estate's own response
(`content-type: application/text`,
`content-disposition: attachment; filename=DC634301.ddl`). */
const filename = `${libref}.ddl`
fs.writeFile(
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-type: text/plain \n Content-Disposition: attachment;filename="${filename}"`,
function (err) {
if (err) throw err
}
`Content-Type: application/text\nContent-disposition: attachment; filename=${filename}`
)
_webout = lines.join('\n') + '\n'
File diff suppressed because it is too large. Load diff
+19 -4
View File
@@ -207,6 +207,17 @@ function _parseCsvLine(line) {
// ─── SAS format <-> ISO conversions ──────────────────────────────────────────
/** The key a payload reports a column under.
*
* SAS upcases the names it hands the client - the getdata, getcols and viewdata
* payloads all arrive upper case - while a table keeps the case it was created
* with, which is what the DDL reports. So the metadata stores the creation case
* and every payload goes through here.
*/
function payloadKey(col) {
return String((col && col.name) || '').toUpperCase()
}
/**
* Column metadata, mirroring %mp_getcols.sas.
*
@@ -245,7 +256,9 @@ function mpGetcols(schemaCols) {
NAME: name,
TYPE: isChar ? 'C' : 'N',
LENGTH: Number(col.length) || 0,
LABEL: col.label || col.name,
// mp_getcols falls back to the name as the payload reports it -
// upper case, whatever case the table was created with
LABEL: col.label || name,
VARNUM: index + 1,
FORMAT: String(col.format || ''),
FMTNAME: fmtname,
@@ -602,10 +615,10 @@ function notNullRules(schema) {
function sasVarsEntry(col) {
const ddtype = getDdType(col)
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
return { format: '$200.', label: col.label || col.name, length: '200', type: 'char' }
return { format: '$200.', label: col.label || payloadKey(col), length: '200', type: 'char' }
}
const fmt = col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')
return { format: fmt, label: col.label || col.name, length: String(col.length), type: col.type === 'N' ? 'num' : 'char' }
return { format: fmt, label: col.label || payloadKey(col), length: String(col.length), type: col.type === 'N' ? 'num' : 'char' }
}
/**
@@ -889,9 +902,11 @@ function mpStreamfile(opts) {
if (typeof Buffer === 'undefined') return false
// A content-type header is what tells the SASjs Server to send webout.txt as
// binary rather than a string (api/src/controllers/internal/Execution.ts).
/* One header per line, as %mfs_httpheader writes them, and the filename
unquoted: %mp_streamfile sends `attachment; filename=&outname`. */
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-type: ${opts.contentType} \n Content-Disposition: attachment;filename="${opts.filename}"`
`Content-Type: ${opts.contentType}\nContent-disposition: attachment; filename=${opts.filename}`
)
fs.writeFileSync(weboutPath, Buffer.from(opts.bytes))
return true
+20 -17
View File
@@ -457,11 +457,11 @@ if (tableData && tableData.columns && tableData.rows) {
const normalised = {}
for (const key of Object.keys(r)) {
const info = colLookup[key.toLowerCase()]
const targetKey = info ? info.name : key
const targetKey = info ? payloadKey(info) : key
normalised[targetKey] = info ? formatCellValue(r[key], info.ddtype) : r[key]
}
for (const col of charColumns) {
const value = normalised[col.name]
const value = normalised[payloadKey(col)]
/*
Only a cell the row does not carry is padded: that is SAS's
missing, which the writer sends as one space. A value the row does
@@ -472,9 +472,9 @@ if (tableData && tableData.columns && tableData.rows) {
as modified on load).
*/
if (value === undefined || value === null) {
normalised[col.name] = ' '
normalised[payloadKey(col)] = ' '
} else if (String(value).trim() !== '') {
normalised[col.name] = String(value).trim()
normalised[payloadKey(col)] = String(value).trim()
}
}
return { ...normalised }
@@ -487,14 +487,15 @@ if (tableData && tableData.columns && tableData.rows) {
the order it was read in.
*/
if (pkColumns.length) {
// the row keys keep the DDL's casing, so the sort keys are resolved to
// the column's own name rather than uppercased: a lowercase DDL would
// otherwise compare undefined against undefined and lose the order
// the rows are keyed the way the payload reports a column, so the sort
// keys are resolved through payloadKey: a table created with lower-case
// names would otherwise compare undefined against undefined and lose
// the order the grid's duplicate detection depends on
const keys = pkColumns.map(name => {
const match = visibleColumns.find(
col => col.name.toUpperCase() === name.toUpperCase()
)
return match ? match.name : name
return match ? payloadKey(match) : name
})
augmentedRows.sort((a, b) => {
for (const key of keys) {
@@ -513,18 +514,20 @@ if (tableData && tableData.columns && tableData.rows) {
// $sasdata vars: temporal columns are char ($200.), others keep their SAS type
const vars = {}
for (const col of visibleColumns) {
vars[col.name] = sasVarsEntry(col)
vars[payloadKey(col)] = sasVarsEntry(col)
}
const colHeaders = visibleColumns.map(c => c.name)
const colHeaders = visibleColumns.map(c => payloadKey(c))
// Determine DTVARS, DTTMVARS, TMVARS from column formats
// Determine DTVARS, DTTMVARS, TMVARS from column formats. These are matched
// against the grid's headers by the client, so they are payload keys like
// every other name the response carries.
let dtVars = [], dttmVars = [], tmVars = []
for (const col of visibleColumns) {
const ddtype = getDdType(col)
if (ddtype === 'DATETIME') dttmVars.push(col.name)
else if (ddtype === 'DATE') dtVars.push(col.name)
else if (ddtype === 'TIME') tmVars.push(col.name)
if (ddtype === 'DATETIME') dttmVars.push(payloadKey(col))
else if (ddtype === 'DATE') dtVars.push(payloadKey(col))
else if (ddtype === 'TIME') tmVars.push(payloadKey(col))
}
// Build cols array. Every column of the table, as getdata.sas emits them:
@@ -564,8 +567,8 @@ if (tableData && tableData.columns && tableData.rows) {
// leaves COLTYPE blank for DATE/DATETIME/TIME and the client types those
// from DDTYPE
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') coltype = ' '
else if (ddtype === 'N') coltype = `{"data":"${raw.name}","type":"numeric","format":"0"}`
else coltype = `{"data":"${raw.name}"}`
else if (ddtype === 'N') coltype = `{"data":"${payloadKey(raw)}","type":"numeric","format":"0"}`
else coltype = `{"data":"${payloadKey(raw)}"}`
const entry = dictionary[String(c.NAME).toUpperCase()]
return {
NAME: c.NAME,
@@ -586,7 +589,7 @@ if (tableData && tableData.columns && tableData.rows) {
let maxLen = 0
const lcName = col.name.toLowerCase()
for (const row of augmentedRows) {
let val = row[col.name]
let val = row[payloadKey(col)]
if (val === undefined) val = row[lcName]
if (val !== undefined && val !== null) {
// SAS measures the value as stored, so a blank character
+25 -15
View File
@@ -30,25 +30,35 @@ if (libds) {
// ─── Load the table schema ────────────────────────────────────────────────────
/*
getcols.sas validates LIBDS against the libraries it can assign before it
reads anything (%mpe_validatecol, then dc_assignlib), so a LIBDS naming a
library the estate does not have aborts "Invalid libds" - it does not go
looking for that member somewhere else. The mock used to search every libref,
which answered for a request the estate refuses, and a missing or malformed
LIBDS arrives at the same abort.
Two checks, and the estate answers them differently.
A member missing from a library that does exist is a different path: the
estate fails inside its query, and there is no message there worth copying.
"Invalid libds" comes from getcols.sas:42, and it is guarded on
%mp_validatecol(_libds,LIBDS,...) - the SHAPE of the LIBDS, a libref of eight
characters or fewer and a member of 32 or fewer, not whether the library
exists. So a malformed LIBDS is that abort, and only a malformed one.
Everything else that cannot be resolved - a well-formed libref the estate does
not have, or a member missing from a library it does have - reaches the second
abort at getcols.sas:66-68, msg=syscc=&syscc. What that says today is
syscc=3000 with a SAS syntax error behind it ("Numeric value found where
character value needed"), for all three of those inputs, which is a defect in
the service rather than a designed message. The mock reproduces it as it is:
the client renders the same abort either way, and inventing a cleaner one here
would describe an estate nobody runs.
*/
const knownLib = libds
? allLibrefs().find(lib => lib.toUpperCase() === libref.toUpperCase())
: null
if (!libds || !table || !knownLib) {
const LIBDS_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
if (!LIBDS_SHAPE.test(String(libds || ''))) {
mpAbort('Invalid libds', _program + '.sas')
}
libref = knownLib
const tableData = makeTableLoader(libDataDir(libref))(table) || null
const knownLib = allLibrefs().find(
lib => lib.toUpperCase() === libref.toUpperCase()
)
const tableData = knownLib
? makeTableLoader(libDataDir(knownLib))(table) || null
: null
if (!knownLib || !tableData) {
mpAbort('syscc=3000', _program + '.sas')
}
// ─── Build the column list ────────────────────────────────────────────────────
// Every column, as getdata.sas emits them: the temporal columns are dropped from
+20 -7
View File
@@ -80,19 +80,32 @@ const tableName =
// have even when the table exists somewhere else, because the abort comes from
// %mp_derivedvars after dc_assignlib has already refused the library.
/* getcolvals.sas validates the SHAPE of libds first, with
%mpe_validatecol(...,LIBDS,...), and that is the only case it answers
"Invalid libds" (mac=&_program..sas). It then assigns the library and leaves
the table to %mp_derivedvars, which aborts "Target table not found: <LIBDS>"
with the macro's own MAC. So a malformed value is not a missing table, and a
well-formed libref the estate does not have is not a malformed one - the
recorded answer for NOLIB.MPE_TABLES is the resolution abort, which is what
the scenario public/getcolvals/wrong_libref now pins. */
const LIBDS_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
const libdsWithoutCatalog =
libds.length > 3 && libds.slice(-3).toUpperCase() === '-FC'
? libds.slice(0, -3)
: libds
if (!LIBDS_SHAPE.test(libdsWithoutCatalog.trim())) {
mpAbort('Invalid libds', _program + '.sas')
}
let _loaded = null
if (namedLibref && tableName) {
/* %mpe_validatecol refuses a libref the estate does not have before the
table is looked for, which is why an unknown library is "Invalid libds"
and a missing member in a known library is "Target table not found". */
const knownLib = allLibrefs().find(
lib => lib.toUpperCase() === namedLibref.toUpperCase()
)
if (!knownLib) {
mpAbort('Invalid libds', _program + '.sas')
if (knownLib) {
const named = makeTableLoader(libDataDir(knownLib))(tableName)
if (named) _loaded = { data: named, libref: knownLib }
}
const named = makeTableLoader(libDataDir(knownLib))(tableName)
if (named) _loaded = { data: named, libref: knownLib }
} else {
_loaded = loadTableAnyLib(tableName)
}
+225 -54
View File
@@ -1,54 +1,225 @@
let mimeType = `application/octet-stream`
let filename = `dc-download`
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const reqLibref =
typeof libref === 'undefined' ? '' : String(libref || '').trim()
const reqDs = typeof ds === 'undefined' ? '' : String(ds || '').trim()
/**
* getddl.sas assigns the library, checks the member is there and aborts with
* "Dataset <libref>.<ds> was not found" - the same abort answers an unknown
* library and a missing member, which is why the message names both parts.
* The mock used to answer its fixed sample body for any request at all,
* including a table it has never heard of.
*/
const requested = reqDs ? makeTableLoader(libDataDir(reqLibref))(reqDs) : null
if (!reqLibref || !reqDs || !requested) {
mpAbort(`Dataset ${reqLibref}.${reqDs} was not found`, _program)
}
filename = `${reqLibref}_${reqDs}.ddl`
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
if (err) throw err;
})
_webout = `/* DDL generated by sassrv on 29SEP2022:11:59:44 */
CREATE SCHEMA DC_JSLIB;
/* Postgres Flavour DDL for DC_JSLIB.MPE_X_TEST */
CREATE TABLE DC_JSLIB.MPE_X_TEST (
"primary_key_field" DOUBLE PRECISION NOT NULL
,"some_char" VARCHAR(32767)
,"some_dropdown" VARCHAR(128)
,"some_num" DOUBLE PRECISION
,"some_date" DOUBLE PRECISION
,"some_datetime" TIMESTAMP
,"some_time" DOUBLE PRECISION
,"some_shortnum" DOUBLE PRECISION
,"some_bestnum" DOUBLE PRECISION
);
CREATE UNIQUE INDEX "primary_key_field" ON DC_JSLIB.MPE_X_TEST(
"primary_key_field"
);
`
/* mp_streamfile(contenttype=TEXT) sends an application/text attachment named
after the member, unquoted - confirmed against the estate's own response for
public/getddl, whose headers are `content-type: application/text` and
`content-disposition: attachment; filename=<libref>_<ds>.ddl`. */
let mimeType = `application/text`
let filename = `dc-download`
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const reqLibref =
typeof libref === 'undefined' ? '' : String(libref || '').trim()
const reqDs = typeof ds === 'undefined' ? '' : String(ds || '').trim()
/**
* getddl.sas assigns the library, checks the member is there and aborts with
* "Dataset <libref>.<ds> was not found" - the same abort answers an unknown
* library and a missing member, which is why the message names both parts.
* The mock used to answer its fixed sample body for any request at all,
* including a table it has never heard of.
*/
const requested = reqDs ? makeTableLoader(libDataDir(reqLibref))(reqDs) : null
if (!reqLibref || !reqDs || !requested) {
mpAbort(`Dataset ${reqLibref}.${reqDs} was not found`, _program)
}
filename = `${reqLibref}_${reqDs}.ddl`
/* ─── the DDL, generated from the table's own metadata ─────────────────────
mp_ds2ddl reads dictionary.columns, dictionary.indexes and the table's
constraints; the mock reads the table file makedata wrote, which carries the
same things - the columns in order at the case the estate reports, and the
unique key. Verified byte-for-byte against the synthetic estate's own DDL for
MPE_TABLES in all three flavours, which is the table the parity gated
scenario records (public/getddl/mpe_tables); no golden pins the other
control tables yet.
*/
const DDL_MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
/**
* The estate's header comes from `%sysfunc(datetime(),datetime19.)`, a SAS
* datetime, so it carries the session's own local time rather than UTC. The
* mock reads the local components for the same reason: the synthetic estate
* answers 07NOV2025:22:27 for a moment whose UTC getters give 21:27, and the
* projection masks the whole comment, so only this choice keeps the two equal.
*/
function ddlDatetime(now) {
const d = new Date(now)
const p = (n) => String(n).padStart(2, '0')
return (
p(d.getDate()) + DDL_MONTHS[d.getMonth()] + d.getFullYear() + ':' +
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
)
}
/** The name as created: the mock's metadata carries the estate's own casing. */
function ddlType(col) {
const isChar = col.type === 'C' || col.type === 'char'
if (isChar) return 'char(' + col.length + ')'
if (Number(col.length) !== 8) return 'num length=' + col.length
return 'num'
}
/**
* One column line, as `put name typ fmt notnul lab` writes it: list output puts
* one space between parts, and a blank part costs a space of its own - which is
* why the trailing blanks differ between a column that is not null and one that
* carries a label.
*/
function ddlColumnLine(col, first) {
const indent = first ? ' ' : ' ,'
const fmt =
col.format && String(col.format).length > 1
? 'format=' + String(col.format).toUpperCase()
: ' '
const notnul = col.notnull ? 'not null' : ' '
const lab =
col.label && String(col.label).length > 1
? "label='" + String(col.label).replace(/'/g, "''") + "'"
: ' '
return indent + [col.name, ddlType(col), fmt, notnul, lab].join(' ')
}
/** Is this numeric column a datetime the way mp_ds2ddl reads the format? */
function ddlIsDatetime(col) {
return String(col.format || '').toUpperCase().indexOf('DATETIME') === 0
}
function ddlPostgresType(col) {
if (col.type === 'C' || col.type === 'char') return 'VARCHAR(' + col.length + ')'
return ddlIsDatetime(col) ? 'TIMESTAMP' : 'DOUBLE PRECISION'
}
function ddlTsqlType(col) {
if (col.type === 'C' || col.type === 'char') {
return '[varchar](' + (Number(col.length) <= 8000 ? col.length : 'max') + ')'
}
return ddlIsDatetime(col) ? '[datetime2](7)' : '[decimal](18,2)'
}
/** The line each dialect writes for a column, blanks and all. */
function ddlDialectColumn(col, first, flavour) {
const indent = first ? ' ' : ' ,'
const notnul = col.notnull ? 'NOT NULL' : ' '
if (flavour === 'PGSQL') {
return indent + ['"' + col.name + '"', ddlPostgresType(col), notnul].join(' ')
}
// the type follows the name with no separator, as mp_ds2ddl writes it
return indent + '[' + col.name + ']' + ddlTsqlType(col) + ' ' + notnul
}
/**
* The DDL mp_ds2ddl writes, from the table file makedata wrote. SAS, TSQL and
* PGSQL each have their own shape, and each was checked against the estate's
* own output for MPE_TABLES - the table the parity gate records.
*/
function ddlText(table, libref, ds, flavour, user, now) {
const lines = []
lines.push('/* DDL generated by ' + user + ' on ' + ddlDatetime(now) + ' */')
/* mp_ds2ddl writes that comment before it branches, and has branches only
for SAS, TSQL and PGSQL: any other flavour leaves the file holding the
comment alone, which mp_streamfile then sends as a normal download. The
estate answers exactly that for an unknown flavour - a 200 with a
55-byte body - so the mock does too rather than inventing a refusal. */
if (['SAS', 'TSQL', 'PGSQL'].indexOf(flavour) < 0) {
return lines.join('\n') + '\n'
}
if (flavour === 'PGSQL') {
lines.push('CREATE SCHEMA ' + libref + ';')
lines.push('/* Postgres Flavour DDL for ' + libref + '.' + ds + ' */')
lines.push('CREATE TABLE ' + libref + '.' + ds + ' (')
table.columns.forEach(function (col, i) {
lines.push(ddlDialectColumn(col, i === 0, 'PGSQL'))
})
lines.push(');')
if (table.uniqueIndex) {
// the index is a second statement in Postgres, after the table
lines.push('CREATE UNIQUE INDEX "' + table.uniqueIndex.name + '" ON ' + libref + '.' + ds + '(')
table.uniqueIndex.columns.forEach(function (name, i) {
lines.push((i === 0 ? ' ' : ' ,') + '"' + name + '"')
})
lines.push(');')
}
return lines.join('\n') + '\n'
}
if (flavour === 'TSQL') {
lines.push('/* TSQL Flavour DDL for ' + libref + '.' + ds + ' */')
lines.push('create table [' + libref + '].[' + ds + '](')
table.columns.forEach(function (col, i) {
lines.push(ddlDialectColumn(col, i === 0, 'TSQL'))
})
if (table.uniqueIndex) {
// TSQL declares the key inside the create table, not after it
lines.push(' ,index [' + table.uniqueIndex.name + '] UNIQUE NONCLUSTERED (')
table.uniqueIndex.columns.forEach(function (name, i) {
lines.push((i === 0 ? ' ' : ' ,') + '[' + name + ']')
})
lines.push(' )')
}
lines.push(')')
lines.push('GO')
/* A label becomes an extended property, one statement per column, and
the statement block is separated from the create by a line holding a
single space - which is what put ' ' / writes. */
table.columns.forEach(function (col) {
if (!col.label || String(col.label).length < 2) return
lines.push(' ')
lines.push('EXEC sys.sp_addextendedproperty ')
lines.push(" @name=N'MS_Description',@value=N'" + String(col.label).replace(/'/g, "''") + "'")
lines.push(" ,@level0type=N'SCHEMA',@level0name=N'" + libref + "' ")
lines.push(" ,@level1type=N'TABLE',@level1name=N'" + ds + "'")
lines.push(" ,@level2type=N'COLUMN',@level2name=N'" + col.name + "'")
})
if (table.columns.some(function (col) { return col.label && String(col.label).length > 1 })) {
lines.push('GO')
}
return lines.join('\n') + '\n'
}
lines.push('/* SAS Flavour DDL for ' + libref + '.' + ds + ' */')
lines.push('proc sql;')
lines.push('create table ' + libref + '.' + ds + '(')
table.columns.forEach(function (col, i) {
lines.push(ddlColumnLine(col, i === 0))
})
lines.push(');')
if (table.uniqueIndex) {
lines.push('CREATE UNIQUE INDEX ' + table.uniqueIndex.name + ' ON ' + libref + '.' + ds + ' (')
table.uniqueIndex.columns.forEach(function (name, i) {
lines.push((i === 0 ? ' ' : ' ,') + name)
})
lines.push(');')
}
return lines.join('\n') + '\n'
}
_webout = ddlText(
requested,
reqLibref,
reqDs,
String(typeof flavour === 'undefined' ? 'SAS' : flavour || 'SAS').toUpperCase(),
typeof _SASJS_USERNAME === 'undefined' ? 'sasjs' : _SASJS_USERNAME,
Date.now()
)
/* The headers go on last because the estate writes none until mp_streamfile,
which runs after mp_ds2ddl has produced the file - so a request that cannot
produce one has already aborted as a JSON envelope by now. One header per
line, as %mfs_httpheader writes them, and synchronously as that macro does,
so they are on disk before the program returns. */
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-Type: ${mimeType}\nContent-disposition: attachment; filename=${filename}`
)
+233 -107
View File
@@ -19,12 +19,31 @@ eval(
const dcLibref = dcReadSettings(driveRoot, appLoc).dcLibref
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
// Helper: load a table JSON file
function loadTableData(tableName) {
/*
Loads a table's JSON file. `libref` defaults to the control library, which is
where the settings and the MPE_TABLES registration live; a download passes the
library it was asked for, because makedata and createdemodata write each
library's tables under data/<libref>.
Returns null when there is no such member. A file that is there but cannot be
read or parsed throws instead: that is a broken fixture, and reporting it as
"not found" would send the next reader looking for a table that exists.
*/
function loadTableFile(libref, tableName) {
const dir = libref ? nodePath.resolve(dataDir, '..', libref) : dataDir
const file = nodePath.resolve(dir, String(tableName).toLowerCase() + '.json')
let raw
try {
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
} catch(err) { return null }
raw = fs.readFileSync(file, {encoding:'utf8'}).toString()
} catch (err) {
if (err.code === 'ENOENT') return null
throw new Error('mock table file unreadable: ' + file + ' (' + err.code + ')')
}
try {
return JSON.parse(raw)
} catch (err) {
throw new Error('mock table file is not valid JSON: ' + file + ' (' + err.message + ')')
}
}
// SAS epoch for date/datetime/time formatting
@@ -51,123 +70,230 @@ function formatSasValue(value, ddtype) {
return String(value)
}
// Determine mime type and filename from request params
let mimeType = 'application/octet-stream'
let filename = table || 'dc-download'
/*
The download contract, read from getrawdata.sas and confirmed against the
estate's own responses: `type` is validated against a fixed list before
anything is written, each type streams under the content type %mp_streamfile
maps it to, and the file is named LIBREF.DS.ext with both parts upcased
(`%let table=%upcase(&table)` ... outname=&table..&ext).
switch((type || '').toUpperCase()) {
case 'CSV':
mimeType = 'text/csv'
filename += '.csv'
break
case 'EXCEL':
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
filename += '.xlsx'
break
case 'MARKDOWN':
mimeType = 'application/octet-stream'
filename += '.md'
break
case 'PDF':
/* the mock layer has no ODS, so it streams the table listing as the
body: enough for the client to check the option, the content type
and the filename, which is what this layer owns. The real report is
laid out by mp_ds2pdf on the SAS side. */
mimeType = 'application/pdf'
filename += '.pdf'
break
default:
mimeType = 'application/octet-stream'
filename += '.sas'
break
SAS application/text LIBREF.DS.sas
CSV application/csv LIBREF.DS.csv
EXCEL openxml sheet LIBREF.DS.xlsx
MARKDOWN text/markdown LIBREF.DS.md
PDF application/pdf LIBREF.DS.pdf
WEBCSV and WEBTAB write their rows straight to _webout and set no headers at
all - the estate answers them with no content-disposition, so an attachment
from the mock would be a download the estate does not offer. EXCEL is the one
type the synthetic estate cannot produce (PROC EXPORT xlsx fails there with
syscc=1012), so its row comes from the service source and mp_streamfile. The
PDF body is the table listing rather than a typeset report - the mock layer
has no ODS - but the content type and filename are this layer's to get right.
*/
const DOWNLOAD_TYPES = {
SAS: { ext: 'sas', contentType: 'application/text' },
CSV: { ext: 'csv', contentType: 'application/csv' },
EXCEL: {
ext: 'xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
},
MARKDOWN: { ext: 'md', contentType: 'text/markdown' },
PDF: { ext: 'pdf', contentType: 'application/pdf' }
}
// Write headers
fs.writeFile(_SASJS_WEBOUT_HEADERS,
`Content-type: ${mimeType} \n Content-Disposition: attachment;filename="${filename}"`,
function (err) { if (err) throw err }
)
/*
getrawdata.sas validates all three inputs in one data step, then aborts in a
fixed order before a byte is streamed: table shape, then the filter as an
integer, then the type against its list - each with mac=&_program..sas. The
rules come from the macros the service calls:
- %mpe_validatecol(...,LIBDS,...) strips a trailing -FC and validates the
rest with %mp_validatecol's /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i, so the
check is on the SHAPE of LIBREF.DATASET and not on whether it exists.
- %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
so 7.0 and 1e3 pass while abc and 1.5 do not, and a missing filter becomes
the string 0 rather than an error.
mpAbort lives in dcMockUtils, which this service cannot eval because its own
helpers collide with it, so the envelope is written here the way mpAbort
writes it: the abort member, no CLOSE.
*/
function abortInput(message, mac) {
webout('OPEN')
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
const stop = new Error(message)
stop.mockAbort = true
throw stop
}
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 =
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 (
!FILTER_NUMBER.test(requestFilter) ||
!isFinite(filterValue) ||
Math.floor(filterValue) !== filterValue
) {
abortInput('Invalid filter')
}
const requestedType = String(typeof type === 'undefined' ? '' : type || '').toUpperCase()
const download = DOWNLOAD_TYPES[requestedType]
const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WEBTAB'
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
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)
let tableName = ''
if (table) {
const parts = table.split('.')
tableName = parts[parts.length - 1]
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.
A LIBREF.NAME-FC request is different: it names a format catalog, which the
estate reads through %mp_cntlout into work.fmtextract and then streams as
LIBREF.NAME.ext. The mock holds a catalog as a member of the same name whose
MPE_TABLES registration carries the -FC suffix, and only that registration
makes the request a catalog - so a suffix on an ordinary dataset is not
answered with the dataset's rows, which would be a successful download of an
object the caller did not name. The estate's own answer for that case is
syscc=1012 from %mp_cntlout, recorded as the scenario
public/getrawdata/catalog_suffix_on_dataset.
*/
const registry = loadTableFile(null, 'MPE_TABLES')
const registryRows = (registry && registry.rows) || []
const findRegistration = (dsn) =>
registryRows.find(
(row) =>
String(row.libref).toUpperCase() === exportLibref &&
String(row.dsn).toUpperCase() === dsn
) || null
const isCatalogRequest =
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
let tableData = null
let registration = null
if (isCatalogRequest) {
registration = findRegistration(tableName + '-FC')
if (registration) tableData = loadTableFile(exportLibref, tableName)
if (!tableData) abortInput('syscc=1012', 'MP_ABORT')
} else {
tableData = loadTableFile(exportLibref, tableName)
if (!tableData) abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
registration = findRegistration(tableName)
}
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 = String(table || '').split('.')[0]
const registry = loadTableData('MPE_TABLES')
const registration =
registry && registry.rows
? registry.rows.find(
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() &&
String(row.dsn).toUpperCase() === tableName.toUpperCase()
)
: null
const dropped = ['var_txfrom', 'var_txto']
.map((key) => (registration ? registration[key] : ''))
.filter((name) => name && String(name).trim() !== '')
.map((name) => String(name).trim().toUpperCase())
const exportColumns = tableData.columns.filter(
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
const dropped = ['var_txfrom', 'var_txto']
.map((key) => (registration ? registration[key] : ''))
.filter((name) => name && String(name).trim() !== '')
.map((name) => String(name).trim().toUpperCase())
const exportColumns = tableData.columns.filter(
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
)
// a CSV download opens with a header row, as the SAS export writes one. The
// other types stream the same lines under their own content type, and none
// of them is a CSV, so they keep starting at the first row
if (requestedType === 'CSV') {
/* the header the SAS export writes is upper case, whatever case the
table was created with. This service carries its own helpers rather
than eval'ing dcMockUtils, so it upcases here rather than calling
payloadKey. */
csvLines.push(
exportColumns.map((col) => String(col.name).toUpperCase()).join(',')
)
}
// a CSV download opens with a header row, as the SAS export writes one. The
// other types stream the same lines under their own content type, and none
// of them is a CSV, so they keep starting at the first row
if (String(type || '').toUpperCase() === 'CSV') {
csvLines.push(exportColumns.map((col) => col.name).join(','))
}
// Build column ddtype lookup
const colDdtypes = {}
for (const col of exportColumns) {
const fmt = (col.format || '').toLowerCase()
let ddtype = col.type === 'N' ? 'N' : 'C'
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
else if (fmt.startsWith('date')) ddtype = 'DATE'
else if (fmt.startsWith('time')) ddtype = 'TIME'
colDdtypes[col.name.toUpperCase()] = ddtype
}
// Build column ddtype lookup
const colDdtypes = {}
for (const row of tableData.rows) {
const values = []
for (const col of exportColumns) {
const fmt = (col.format || '').toLowerCase()
let ddtype = col.type === 'N' ? 'N' : 'C'
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
else if (fmt.startsWith('date')) ddtype = 'DATE'
else if (fmt.startsWith('time')) ddtype = 'TIME'
colDdtypes[col.name.toUpperCase()] = ddtype
}
for (const row of tableData.rows) {
const values = []
for (const col of exportColumns) {
let val = row[col.name]
if (val === undefined) val = row[col.name.toLowerCase()]
if (val === undefined) {
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
if (key) val = row[key]
}
if (val === null || val === undefined) {
val = ''
} else {
const ddtype = colDdtypes[col.name.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
val = formatSasValue(val, ddtype)
} else {
val = String(val)
}
// Quote if it contains a delimiter, a quote or a line break -
// a description with a newline is still one CSV record
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
val = '"' + val.replace(/"/g, '""') + '"'
}
}
values.push(val)
let val = row[col.name]
if (val === undefined) val = row[col.name.toLowerCase()]
if (val === undefined) {
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
if (key) val = row[key]
}
csvLines.push(values.join(','))
if (val === null || val === undefined) {
val = ''
} else {
const ddtype = colDdtypes[col.name.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
val = formatSasValue(val, ddtype)
} else {
val = String(val)
}
// Quote if it contains a delimiter, a quote or a line break -
// a description with a newline is still one CSV record
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
val = '"' + val.replace(/"/g, '""') + '"'
}
}
values.push(val)
}
csvLines.push(values.join(','))
}
_webout = csvLines.join('\n')
/* The headers go on last, as the estate's do: %mp_streamfile runs after the
file has been written, so nothing describes a download that failed. One
header per line, as %mfs_httpheader writes them, and the filename unquoted.
WEBCSV and WEBTAB have no entry in DOWNLOAD_TYPES and so send none. */
if (download) {
/* Synchronously, as %mfs_httpheader writes them (fopen/fput/fclose on the
same file), so the headers are on disk when the program returns: a
caller - the runtime, or a test - never has to wait for a write. */
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-Type: ${download.contentType}\nContent-disposition: attachment; filename=${filename}`
)
}
+5 -5
View File
@@ -368,7 +368,7 @@ if (tableData && tableData.columns) {
stringifies to.
*/
if (val === undefined || val === null || val === '') {
row[col.name] = col.type === 'N' ? '.' : ' '
row[payloadKey(col)] = col.type === 'N' ? '.' : ' '
} else {
/*
Only trailing blanks are noise - the width SAS pads to - and
@@ -381,7 +381,7 @@ if (tableData && tableData.columns) {
viewCell or formatSasValue, both of which return text.
*/
const text = String(val).replace(/ +$/, '')
row[col.name] = text === '' ? ' ' : text
row[payloadKey(col)] = text === '' ? ' ' : text
}
}
return row
@@ -397,7 +397,7 @@ if (tableData && tableData.columns) {
const emptyRow = {}
// a blank character cell is a space everywhere else in this service,
// so the empty row says the same thing
for (const col of physicalColumns) emptyRow[col.name] = col.type === 'N' ? '.' : ' '
for (const col of physicalColumns) emptyRow[payloadKey(col)] = col.type === 'N' ? '.' : ' '
viewdata = [emptyRow]
}
@@ -417,7 +417,7 @@ if (tableData && tableData.columns) {
vars = {}
for (const col of physicalColumns) {
const isTemporal = ['DATE', 'DATETIME', 'TIME'].indexOf(getDdType(col)) >= 0
vars[col.name] = {
vars[payloadKey(col)] = {
format: isTemporal
? String(
col.format ||
@@ -426,7 +426,7 @@ if (tableData && tableData.columns) {
: 'DATETIME19.3')
).toUpperCase()
: (col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')),
label: col.label || col.name,
label: col.label || payloadKey(col),
length: String(col.length),
type: col.type === 'N' ? 'num' : 'char'
}
+2 -1
View File
@@ -86,7 +86,8 @@ test('MPE_X_TEST carries the excel/viewer fixtures, not the SAS demo rules', ()
)
)
const byName = {}
for (const column of table.columns) byName[column.name] = column.label
// the table keeps the case the SAS model creates it with, which is lower
for (const column of table.columns) byName[column.name.toUpperCase()] = column.label
assert.equal(byName.SOME_CHAR, 'Some Character Column')
assert.equal(byName.SOME_DATE, 'Some Date')
assert.equal(byName.SOME_NUM, '', 'a blank label falls back to the name')
+368
View File
@@ -0,0 +1,368 @@
/**
* What the download services send, as opposed to what they say. The parity gate
* projects a DDL down to its `text` member after masking the "DDL generated by"
* comment and carries no response headers at all, so neither a header timestamp
* nor any content type or filename is visible to it: these tests are the only
* thing that pins them, and each answers the estate's own response rather than
* the mock's intent.
*
* The timezone is fixed to a zone three hours from UTC, with no daylight
* saving, so "local" and "UTC" are always different and a regression to the
* UTC getters cannot pass by coincidence. Each test file runs in its own
* process, so setting TZ here does not reach the other suites.
*/
process.env.TZ = 'Etc/GMT-3'
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { seededEstate, runService } = require('./harness')
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
/**
* The wall clock the header prints, read as if it were UTC - which is how it
* can be compared with a Date.UTC() built from the same fields.
*/
function parsedWallClock(body) {
const m = /\/\* DDL generated by \S+ on +(\d{2})([A-Z]{3})(\d{4}):(\d{2}):(\d{2}):(\d{2}) \*\//.exec(body)
assert.ok(m, 'the DDL carries its generated-by comment: ' + body.slice(0, 80))
return Date.UTC(
Number(m[3]),
MONTHS.indexOf(m[2]),
Number(m[1]),
Number(m[4]),
Number(m[5]),
Number(m[6])
)
}
test('getddl stamps the DDL in the session time, not UTC', () => {
const estate = seededEstate({ demo: false })
const before = new Date()
const body = runService(estate, 'public/getddl', {
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
})
const after = new Date()
/* getddl.sas writes `%sysfunc(datetime(),datetime19.)`, a SAS datetime, so
the clock it prints is the session's own: the synthetic estate answers
07NOV2025:22:27 for a moment whose UTC getters give 21:27. Here the
session is three hours ahead, so the printed clock is the UTC clock plus
three hours. */
const utcWall = Date.UTC(
before.getUTCFullYear(),
before.getUTCMonth(),
before.getUTCDate(),
before.getUTCHours(),
before.getUTCMinutes(),
before.getUTCSeconds()
)
const expected = utcWall + 3 * 60 * 60 * 1000
const printed = parsedWallClock(body)
// a run has to finish inside the two-minute window the comparison allows
assert.ok(after - before < 120000)
assert.ok(
Math.abs(printed - expected) <= 120000,
'the DDL is stamped ' + new Date(printed).toISOString() + ' (read as UTC), expected the local clock ' +
new Date(expected).toISOString() + ' - the UTC getters would print ' + new Date(utcWall).toISOString()
)
})
test('the download headers are the two %mfs_httpheader writes, one per line', async () => {
const estate = seededEstate({ demo: false })
runService(estate, 'public/getddl', {
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
})
const headers = headersOf(estate)
assert.deepEqual(headers.split('\n'), [
'Content-Type: application/text',
'Content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES.ddl'
])
})
test('an aborted DDL request writes no download headers', async () => {
const estate = seededEstate({ demo: false })
const body = runService(estate, 'public/getddl', {
params: { libref: 'DC_JSLIB', ds: 'NO_SUCH_DS', flavour: 'SAS' }
})
assert.match(body, /sasjsAbort/)
assert.doesNotMatch(body, /DDL generated by/)
/* Every abort this service can produce happens before the headers are
written, which is where the estate's are too: it writes none until
%mp_streamfile, after %mp_ds2ddl has produced the file. So an aborted
request describes no download, and the write being synchronous is what
makes this assertion mean "never written" rather than "not written yet". */
assert.equal(headersOf(estate), '', 'an aborted request must not describe a download')
})
/*
Every service in this file writes its headers synchronously, as %mfs_httpheader
does (fopen/fput/fclose on the same file), so the file is complete when
runService returns. That is why there is no polling here and why the absence
assertions mean something: no write can still be in flight when they read.
*/
function headersOf(estate) {
return fs.existsSync(estate.headersPath)
? fs.readFileSync(estate.headersPath, 'utf8')
: ''
}
// ─── public/getrawdata ───────────────────────────────────────────────────────
test('getrawdata downloads a CSV the way the estate does', async () => {
const estate = seededEstate({ demo: false })
// the caller's case does not survive: getrawdata.sas upcases the LIBDS
runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'dc_jslib.mpe_selectbox', filter: '0' }
})
/* The estate answers
content-type: application/csv
content-disposition: attachment; filename=DC634301.MPE_SELECTBOX.csv
- the file is named from the upcased LIBREF.DS getrawdata.sas builds. */
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX.csv'
])
})
test('getrawdata names the SAS and Markdown downloads as the estate does', async () => {
const estate = seededEstate({ demo: false })
// the estate's own responses for these two types, less its libref
const cases = [
['SAS', 'application/text', '.sas'],
['MARKDOWN', 'text/markdown', '.md']
]
for (const [type, contentType, ext] of cases) {
runService(estate, 'public/getrawdata', {
params: { type, table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
})
const lines = headersOf(estate).split('\n')
assert.deepEqual(
lines,
[
'Content-Type: ' + contentType,
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX' + ext
],
type
)
}
})
test('getrawdata sends no attachment headers for the direct-to-webout types', async () => {
const estate = seededEstate({ demo: false })
const body = runService(estate, 'public/getrawdata', {
params: { type: 'WEBCSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
})
// the estate writes its rows straight to _webout and sets no headers at all,
// so a content-disposition here would advertise a download it does not offer
assert.ok(body.length > 0)
assert.doesNotMatch(body, /sasjsAbort/)
assert.equal(headersOf(estate), '')
})
test('getrawdata aborts an unknown type before describing any download', async () => {
const estate = seededEstate({ demo: false })
const body = runService(estate, 'public/getrawdata', {
params: { type: 'FOOBAR', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
})
// getrawdata.sas validates type against its list and aborts with mac=
// &_program..sas; the estate's own answer for FOOBAR is exactly this
assert.deepEqual(JSON.parse(body).sasjsAbort, [
{
MSG: 'Invalid type',
MAC: '/Public/app/dc/services/public/getrawdata.sas'
}
])
assert.equal(headersOf(estate), '')
})
test('getrawdata validates the table before it streams, as the estate does', () => {
const estate = seededEstate({ demo: false })
const cases = [
// %mp_validatecol's LIBDS rule is the SHAPE libref.dataset, not existence
[{ type: 'CSV', table: 'NOT_A_LIBDS', filter: '0' }, 'Invalid table'],
[{ type: 'CSV', table: 'TOOLONGLIBREF.X', filter: '0' }, 'Invalid table'],
// an absent table aborts like any other bad shape; it must not throw
[{ type: 'CSV', filter: '0' }, 'Invalid table'],
/* getrawdata.sas aborts in a fixed order - table, then filter, then type -
so a request that fails all three answers the table message */
[{ type: 'FOOBAR', table: 'NOT_A_LIBDS', filter: 'abc' }, 'Invalid table']
]
for (const [params, message] of cases) {
const body = runService(estate, 'public/getrawdata', { params })
assert.deepEqual(
JSON.parse(body).sasjsAbort,
[{ MSG: message, MAC: '/Public/app/dc/services/public/getrawdata.sas' }],
JSON.stringify(params)
)
assert.equal(headersOf(estate), '', JSON.stringify(params))
}
})
test('getrawdata reads the filter the way the estate does', () => {
const estate = seededEstate({ demo: false })
/* 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 }
})
assert.deepEqual(
JSON.parse(body).sasjsAbort,
[{ MSG: 'Invalid filter', MAC: '/Public/app/dc/services/public/getrawdata.sas' }],
filter
)
assert.equal(headersOf(estate), '', filter)
}
/* 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, /Invalid filter/, filter)
assert.equal(headersOf(estate).split('\n')[0], 'Content-Type: application/csv', filter)
}
})
test('getrawdata answers a catalog name with the catalog, never the dataset', () => {
const estate = seededEstate({ demo: false })
/*
A LIBREF.NAME-FC request names a format catalog: getrawdata.sas reads it
through %mp_cntlout and streams LIBREF.NAME.ext. The mock's catalog is a member
with no columns whose MPE_TABLES registration carries the -FC suffix, and
DC_JSLIB.MPE_X_CATALOG is the one it holds. The fixture has no catalog
entries, so the listing is empty - the divergence from the estate's cntlout
columns is recorded in the parity README - but it must be the catalog's
filename and body, not MPE_X_CATALOG's sibling dataset.
*/
const catalog = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_X_CATALOG-FC', filter: '0' }
})
assert.doesNotMatch(catalog, /sasjsAbort/)
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=DC_JSLIB.MPE_X_CATALOG.csv'
])
assert.ok(catalog.indexOf('SELECTBOX_RK') < 0, 'the catalog is not answered with a dataset\'s rows')
/*
A suffix on an ordinary dataset is not a catalog, and the estate answers it
with syscc=1012 from %mp_cntlout - recorded as the scenario
public/getrawdata/catalog_suffix_on_dataset, which is why the mock does not
fall back to the dataset either.
*/
const dataset = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX-FC', filter: '0' }
})
assert.deepEqual(JSON.parse(dataset).sasjsAbort, [
{ MSG: 'syscc=1012', MAC: 'MP_ABORT' }
])
assert.equal(headersOf(estate), '')
})
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)
}
})
test('getrawdata names a broken table file instead of calling it missing', () => {
const estate = seededEstate({ demo: false })
const file = path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_selectbox.json')
fs.writeFileSync(file, '{ this is not json')
/* A member that cannot be parsed is a broken fixture, not an absent table:
mapping it onto "Target table not found" sends the next reader looking for
a table that is right there. */
assert.throws(
() =>
runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
}),
/mock table file is not valid JSON: .*mpe_selectbox\.json/
)
})
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
const estate = seededEstate({ demo: false })
runService(estate, 'admin/exportdb', { params: { flavour: 'SAS' } })
/* exportdb.sas: %mp_streamfile(contenttype=TEXT, outname=&dc_libref..ddl);
the estate answers application/text and filename=DC634301.ddl. */
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/text',
'Content-disposition: attachment; filename=DC_JSLIB.ddl'
])
})
test('the shared mpStreamfile writes the same two header lines', async () => {
const estate = seededEstate()
runService(estate, 'admin/exportconfig', { params: {} })
/* exportconfig.sas streams a zip through %mp_streamfile(contenttype=ZIP,
outname=DCBACKUP.zip), so the helper every binary stream goes through has
to write the same shape the two services above test by hand. */
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/zip',
'Content-disposition: attachment; filename=DCBACKUP.zip'
])
})
+57 -4
View File
@@ -54,9 +54,36 @@ test('getddl names the file after the member it is for', async () => {
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
})
const pattern = /filename="DC_JSLIB_MPE_TABLES\.ddl"/
// mp_streamfile(contenttype=TEXT) sends an application/text attachment, and
// names it without quoting - the estate's own response headers are
// `content-type: application/text` and
// `content-disposition: attachment; filename=DC634301_MPE_TABLES.ddl`
const pattern = /content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES\.ddl/i
const headers = await headersWhen(estate, pattern)
assert.match(headers, pattern)
assert.match(headers, /content-type: application\/text/i)
assert.doesNotMatch(headers, /application\/octet-stream/i)
})
test('getddl answers an unknown flavour the way the estate does', async () => {
const estate = seededEstate({ demo: false })
const params = { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'XXYY' }
const body = runService(estate, 'public/getddl', { params })
/* getddl.sas passes the flavour straight to mp_ds2ddl, which has branches for
SAS, TSQL and PGSQL only: anything else leaves the file holding its opening
comment, and mp_streamfile sends it. So the answer is a download, not an
abort - and the estate's own response for an unknown flavour is a 200 whose
body is that one line. */
assert.doesNotMatch(body, /sasjsAbort/)
assert.match(body, /^\/\* DDL generated by \S+ on +\S+ \*\/\n$/)
// the headers are still those of a download, written after the body
const headers = await headersWhen(
estate,
/content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES\.ddl/i
)
assert.match(headers, /content-type: application\/text/i)
})
test('getcolvals aborts when the table is not there', () => {
@@ -83,13 +110,14 @@ test('getcolvals aborts when the table is not there', () => {
assert.equal(body.vals, undefined)
})
test('getcolvals aborts on a library it does not have, even for a real table', () => {
test('getcolvals refuses a malformed libds before it looks anything up', () => {
const estate = seededEstate({ demo: false })
const body = JSON.parse(
runService(estate, 'public/getcolvals', {
tableName: 'iwant',
rows: [
{
// an 11-character libref: %mpe_validatecol's LIBDS rule is shape only
libds: 'NO_SUCH_LIB.MPE_TABLES',
col: 'RULE_TYPE',
STARTROW: 1,
@@ -99,8 +127,7 @@ test('getcolvals aborts on a library it does not have, even for a real table', (
})
)
// the library is checked before the member is looked for: MPE_TABLES is in
// DC_JSLIB, and searching every libref would answer where the estate refuses
// the shape abort is the service's own, from getcolvals.sas:124-127
assert.deepEqual(body.sasjsAbort, [
{
MSG: 'Invalid libds',
@@ -109,3 +136,29 @@ test('getcolvals aborts on a library it does not have, even for a real table', (
])
assert.equal(body.vals, undefined)
})
test('getcolvals aborts a well-formed library it does not have as a missing table', () => {
const estate = seededEstate({ demo: false })
const body = JSON.parse(
runService(estate, 'public/getcolvals', {
tableName: 'iwant',
rows: [
{
// NOLIB passes the shape rule, so this is a resolution failure
libds: 'NOLIB.MPE_TABLES',
col: 'RULE_TYPE',
STARTROW: 1,
ROWS: 5
}
]
})
)
/* The estate assigns the library, then %mp_derivedvars aborts, so the MAC is
the macro's default rather than the service - recorded as the golden for
public/getcolvals/wrong_libref. */
assert.deepEqual(body.sasjsAbort, [
{ MSG: 'Target table not found: NOLIB.MPE_TABLES', MAC: 'MP_ABORT' }
])
assert.equal(body.vals, undefined)
})
+6 -6
View File
@@ -114,12 +114,12 @@ function runService(
*/
estate.sessionCount = (estate.sessionCount || 1) + 1
const sessionDir = path.join(estate.root, 'sessions', 's' + estate.sessionCount)
/* Earlier sessions are left in place. Removing them pruned the estate, but a
service that writes its headers with the async fs.writeFile lands after the
program returns - so the prune could delete the directory under a pending
write and the callback's error took the test process down with it (an
intermittent ENOENT on stpsrv_header.txt). Each run still gets its own
directory, which is what keeps a stale body from being served; the whole
/* Earlier sessions are left in place. Pruning them kept the estate tidy, but
it only ever bought tidiness: a service that wrote its headers with an
async fs.writeFile used to land after the program returned, and the prune
could delete the directory under that pending write. The header writers are
synchronous now, so there is nothing to race, but each run keeps its own
directory because that is what stops a stale body being served, and the
estate is removed when the process exits. */
fs.mkdirSync(sessionDir, { recursive: true })
const runWeboutPath = path.join(sessionDir, 'webout.txt')
+41 -1
View File
@@ -126,8 +126,10 @@ test('each table has the model columns, in the model order', () => {
)
assert.ok(fs.existsSync(file), name + ' is missing')
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
// the parser upper-cases what it reads out of the macro, and the mock
// keeps the case SAS creates the table with - the comparison is on names
assert.deepEqual(
(table.columns || []).map((col) => col.name),
(table.columns || []).map((col) => col.name.toUpperCase()),
expected,
name + ' columns differ from mpe_makedatamodel.sas'
)
@@ -240,3 +242,41 @@ test('makedata leaves no Cypress fixture behind', () => {
)
assert.deepEqual(fixture, [], 'a fixture table is registered by makedata')
})
test('every control table carries the unique key the estate creates it with', () => {
// mp_ds2ddl builds CREATE UNIQUE INDEX from dictionary.indexes, so the key is
// part of the table's definition here too - and the one table without one on
// the estate is named rather than left to drift.
const estate = seededEstate({ demo: false })
// MPE_REQUESTS is the one control table the estate creates without a unique
// index; MPE_X_CATALOG is the format catalog marker, which is a catalog
// rather than a dataset and has no columns to key
const withoutKey = ['MPE_REQUESTS', 'MPE_X_CATALOG']
const files = fs
.readdirSync(path.join(estate.appDir, 'data', 'DC_JSLIB'))
.filter((f) => f.endsWith('.json'))
for (const file of files) {
const table = JSON.parse(
fs.readFileSync(path.join(estate.appDir, 'data', 'DC_JSLIB', file), 'utf8')
)
const expected = withoutKey.indexOf(table.name) < 0
assert.equal(
!!(table.uniqueIndex && table.uniqueIndex.name && table.uniqueIndex.columns.length),
expected,
table.name + (expected ? ' has no unique key' : ' should have no unique key')
)
/* The index map and the schema are two lists of the same column names and
nothing else ties them together: a rename in one would otherwise leave
getddl emitting CREATE UNIQUE INDEX on a column the table does not have,
and the test above would still pass. */
if (!expected) continue
const names = (table.columns || []).map((col) => col.name.toUpperCase())
for (const keyed of table.uniqueIndex.columns) {
assert.ok(
names.indexOf(keyed.toUpperCase()) >= 0,
table.name + ' indexes ' + keyed + ', which is not one of its columns'
)
}
}
})
+14 -11
View File
@@ -36,11 +36,11 @@ test('viewlibs lists the library the settings file names', () => {
'the seeded estate names its control library'
)
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
const after = JSON.parse(runService(estate, 'public/viewlibs', {})).saslibs
assert.ok(
after.some((row) => row.LIBRARYREF === 'DC7QATEST'),
after.some((row) => row.LIBRARYREF === 'DC7QATST'),
'viewlibs follows the settings file rather than a constant'
)
})
@@ -50,14 +50,14 @@ test('getdata reads its table from the library the settings file names', () => {
// move the control library's data files to the new libref, as a deployment
// with a different DCPATH would
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
const to = path.join(estate.appDir, 'data', 'DC7QATST')
fs.renameSync(from, to)
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
const body = JSON.parse(
runService(estate, 'editors/getdata', {
tableName: 'SASControlTable',
rows: [{ LIBDS: 'DC7QATEST.MPE_TABLES', FILTER_RK: -1 }]
rows: [{ LIBDS: 'DC7QATST.MPE_TABLES', FILTER_RK: -1 }]
})
)
@@ -71,7 +71,7 @@ test('a converted service reads the library the settings file names', () => {
writeTable(estate, 'MPE_SUBMIT', [
{
TABLE_ID: 'dc7qatest_1',
BASE_LIB: 'DC7QATEST',
BASE_LIB: 'DC7QATST',
BASE_DS: 'MPE_TABLES',
SUBMIT_STATUS_CD: 'SUBMITTED',
SUBMITTED_BY_NM: 'someone',
@@ -83,9 +83,9 @@ test('a converted service reads the library the settings file names', () => {
])
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
const to = path.join(estate.appDir, 'data', 'DC7QATST')
fs.renameSync(from, to)
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
const body = JSON.parse(runService(estate, 'approvers/getapprovals', {}))
@@ -174,13 +174,16 @@ test('a getrawdata export comes from the library the settings file names', () =>
// reach, and the one that would silently drift
const estate = seededEstate({ demo: false })
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
/* Eight characters, because that is the limit a LIBREF has: the service
validates the LIBDS shape before it reads anything, and the estate answers
a nine-character one with Invalid table. */
const to = path.join(estate.appDir, 'data', 'DC7QATST')
fs.renameSync(from, to)
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
const csv = String(
runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC7QATEST.MPE_SELECTBOX', filter: '0' }
params: { type: 'CSV', table: 'DC7QATST.MPE_SELECTBOX', filter: '0' }
})
)
+6 -2
View File
@@ -74,12 +74,17 @@ Run from the repository root, with the estate reachable and `sas/.env.server` ho
Two refusals are worth recognising. `REFUSED to publish` names a literal the projection kept - the raw response is in `.captures/<service>/<id>.raw.json`, gitignored, so the refusal can be diagnosed without hitting the estate again; fix the projection or the service, never the golden. A missing `sas/.env.server` means no credentials on this machine: the refresh is an agent or maintainer job, never a CI one, and the target must stay inside `SYNTHETIC_TARGETS`.
## The case a column name is reported in
A table keeps the case SAS created it with - `proc sql create table` with `alert_event` leaves `alert_event` in `dictionary.columns`, and the DDL reports it that way - while every payload the client reads (`getdata`, `getcols`, `viewdata`, the variables in `$sasdata`/`$viewdata`) reports the name upper case. So the mock's table files store the creation case, which is what the DDL generator prints, and every service builds its payload keys through `payloadKey()` in `dcMockUtils.js`. A service that reads a row by `col.name` instead of `payloadKey(col)` gets `undefined`, which shows up as an empty grid rather than as an error.
## Fixture divergences that are deliberate
`createdemodata` owns two differences from a SAS estate, both driven by existing specs rather than by the product:
- MPE_X_TEST loses one of the rules `mpe_makedata.sas` seeds for it: the `HARDREGEX` on SOME_SHORTNUM, which rejects 1-5. `regular_excel.xlsx` - the file the specs upload to `dc_jslib` - holds five rows where SOME_SHORTNUM is 5, so with the rule in place that upload is refused before it reaches the approval screen. SAS's other rules for the table are kept, because they are the ones the mock has always had and `editor.cy.ts` exercises them (the dropdown on a cell comes from the `HARDSELECT_HOOK` on SOME_NUM).
- MPE_X_TEST's SOME_CHAR/SOME_DATE/SOME_DATETIME/SOME_TIME carry LABELs that differ from their names, which `viewer-labels.cy.ts` asserts, while a SAS estate has no labels on that table.
- `MPE_X_CATALOG` holds no format entries, and `viewtables` identifies a catalog by the absence of variables, so a `-FC` download of it is an empty listing where the estate's `%mp_cntlout` would produce the FMTNAME/START/END/LABEL/TYPE columns with no rows. The service path around it is pinned instead: `public/getrawdata/catalog_suffix_on_dataset` records what the estate answers when the suffix names something that is not a catalog, and the mock answers that case the same way.
Both are asserted in `test/createdemodata.test.js`, so a change to either side is a failing test rather than a broken Cypress run. A future scenario covering `editors/getdata` on MPE_X_TEST would show them as accepted differences; today the startup scenario does not touch that table.
@@ -98,8 +103,7 @@ Some answers are estate state rather than service behaviour, and a golden for th
Next, and why the obvious recording does not work:
- **A table the caller may not see.** `%mpe_accesscheck` lets an admin through, and the synthetic estate's user is in `DC_ADMIN_GROUP`, so every table is editable there and the "not authorised to edit" abort cannot be produced without changing the estate's security rows - which would make the golden a recording of that change. It needs an estate with a non-admin login before it can be recorded.
- **The `public/getddl` body.** The abort is recorded; the DDL text is not. `mp_ds2ddl` builds it from `dictionary.columns`, `dictionary.indexes` and the table's constraints, and the mock's table files carry columns but no index or constraint metadata, so the mock would have to invent the `CREATE UNIQUE INDEX` block. Give the mock seed the key metadata first, then record the download.
- **`public/getcols` on a libref that is not assigned.** The estate aborts `Invalid libds`; the mock falls back to searching every libref and answers. Tightening it is a behaviour change for the specs that pass a libref the mock does not hold a folder for, so it needs a survey of those callers before the golden.
- **The older dialects of `public/getddl` that the client does not offer.** All three flavours the download menu names - SAS, TSQL and PGSQL - are generated from the table's own metadata, and each is recorded for `MPE_TABLES`, which is the table that pins them. A fourth flavour, or a change to one of these, needs its own recording before it is generated.
See the parity section of the `data-controller` skill for the surrounding workflow.
@@ -15,10 +15,10 @@
"body": {
"sasjsAbort": [
{
"MSG": "Invalid libds",
"MSG": "syscc=3000",
"MAC": "/Public/app/dc/services/public/getcols.sas"
}
],
"SYSCC": "0"
"SYSCC": "3000"
}
}
@@ -0,0 +1,24 @@
{
"_meta": {
"scenario": "malformed_libds",
"service": "public/getcolvals",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-08",
"tls": "verified",
"members": [
"sasjsAbort"
]
},
"body": {
"sasjsAbort": [
{
"MSG": "Invalid libds",
"MAC": "/Public/app/dc/services/public/getcolvals.sas"
}
],
"SYSCC": "0"
}
}
@@ -6,7 +6,7 @@
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-07",
"captured": "2026-11-08",
"tls": "verified",
"members": [
"sasjsAbort"
@@ -15,8 +15,8 @@
"body": {
"sasjsAbort": [
{
"MSG": "Invalid libds",
"MAC": "/Public/app/dc/services/public/getcolvals.sas"
"MSG": "Target table not found: NOLIB.MPE_TABLES",
"MAC": "MP_ABORT"
}
],
"SYSCC": "0"
@@ -0,0 +1,18 @@
{
"_meta": {
"scenario": "mpe_tables",
"service": "public/getddl",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "unknown",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"text"
]
},
"body": {
"text": "/* DDL generated by <USER> on <DATETIME> */\n/* SAS Flavour DDL for <LIBREF>.MPE_TABLES */\nproc sql;\ncreate table <LIBREF>.MPE_TABLES(\n tx_from num format=DATETIME19.3 not null \n ,tx_to num format=DATETIME19.3 not null \n ,libref char(8) not null \n ,dsn char(32) not null \n ,num_of_approvals_required num \n ,loadtype char(12) \n ,buskey char(1000) \n ,var_txfrom char(32) \n ,var_txto char(32) \n ,var_busfrom char(32) \n ,var_busto char(32) \n ,var_processed char(32) \n ,close_vars char(500) \n ,pre_edit_hook char(200) \n ,post_edit_hook char(200) \n ,pre_approve_hook char(200) \n ,post_approve_hook char(200) \n ,signoff_cols char(500) \n ,signoff_hook char(200) \n ,notes char(1000) \n ,rk_underlying char(1000) \n ,audit_libds char(41) \n);\nCREATE UNIQUE INDEX pk_mpe_tables ON <LIBREF>.MPE_TABLES (\n tx_to\n ,libref\n ,dsn\n);\n"
}
}
@@ -0,0 +1,18 @@
{
"_meta": {
"scenario": "mpe_tables_pgsql",
"service": "public/getddl",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "unknown",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"text"
]
},
"body": {
"text": "/* DDL generated by <USER> on <DATETIME> */\nCREATE SCHEMA <LIBREF>;\n/* Postgres Flavour DDL for <LIBREF>.MPE_TABLES */\nCREATE TABLE <LIBREF>.MPE_TABLES (\n \"tx_from\" TIMESTAMP NOT NULL\n ,\"tx_to\" TIMESTAMP NOT NULL\n ,\"libref\" VARCHAR(8) NOT NULL\n ,\"dsn\" VARCHAR(32) NOT NULL\n ,\"num_of_approvals_required\" DOUBLE PRECISION \n ,\"loadtype\" VARCHAR(12) \n ,\"buskey\" VARCHAR(1000) \n ,\"var_txfrom\" VARCHAR(32) \n ,\"var_txto\" VARCHAR(32) \n ,\"var_busfrom\" VARCHAR(32) \n ,\"var_busto\" VARCHAR(32) \n ,\"var_processed\" VARCHAR(32) \n ,\"close_vars\" VARCHAR(500) \n ,\"pre_edit_hook\" VARCHAR(200) \n ,\"post_edit_hook\" VARCHAR(200) \n ,\"pre_approve_hook\" VARCHAR(200) \n ,\"post_approve_hook\" VARCHAR(200) \n ,\"signoff_cols\" VARCHAR(500) \n ,\"signoff_hook\" VARCHAR(200) \n ,\"notes\" VARCHAR(1000) \n ,\"rk_underlying\" VARCHAR(1000) \n ,\"audit_libds\" VARCHAR(41) \n);\nCREATE UNIQUE INDEX \"pk_mpe_tables\" ON <LIBREF>.MPE_TABLES(\n \"tx_to\"\n ,\"libref\"\n ,\"dsn\"\n);\n"
}
}
@@ -0,0 +1,18 @@
{
"_meta": {
"scenario": "mpe_tables_tsql",
"service": "public/getddl",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "unknown",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"text"
]
},
"body": {
"text": "/* DDL generated by <USER> on <DATETIME> */\n/* TSQL Flavour DDL for <LIBREF>.MPE_TABLES */\ncreate table [<LIBREF>].[MPE_TABLES](\n [tx_from][datetime2](7) NOT NULL\n ,[tx_to][datetime2](7) NOT NULL\n ,[libref][varchar](8) NOT NULL\n ,[dsn][varchar](32) NOT NULL\n ,[num_of_approvals_required][decimal](18,2) \n ,[loadtype][varchar](12) \n ,[buskey][varchar](1000) \n ,[var_txfrom][varchar](32) \n ,[var_txto][varchar](32) \n ,[var_busfrom][varchar](32) \n ,[var_busto][varchar](32) \n ,[var_processed][varchar](32) \n ,[close_vars][varchar](500) \n ,[pre_edit_hook][varchar](200) \n ,[post_edit_hook][varchar](200) \n ,[pre_approve_hook][varchar](200) \n ,[post_approve_hook][varchar](200) \n ,[signoff_cols][varchar](500) \n ,[signoff_hook][varchar](200) \n ,[notes][varchar](1000) \n ,[rk_underlying][varchar](1000) \n ,[audit_libds][varchar](41) \n ,index [pk_mpe_tables] UNIQUE NONCLUSTERED (\n [tx_to]\n ,[libref]\n ,[dsn]\n )\n)\nGO\n"
}
}
@@ -0,0 +1,18 @@
{
"_meta": {
"scenario": "unknown_flavour",
"service": "public/getddl",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "unknown",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"text"
]
},
"body": {
"text": "/* DDL generated by <USER> on <DATETIME> */\n"
}
}
@@ -0,0 +1,24 @@
{
"_meta": {
"scenario": "catalog_suffix_on_dataset",
"service": "public/getrawdata",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-08",
"tls": "verified",
"members": [
"sasjsAbort"
]
},
"body": {
"sasjsAbort": [
{
"MSG": "syscc=1012",
"MAC": "MP_ABORT"
}
],
"SYSCC": "1012"
}
}
@@ -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"
}
}
+7 -1
View File
@@ -69,7 +69,13 @@ const PLACEHOLDERS = [
[/\/tmp\/[^\s"']+/g, '<TMP>'],
[/https?:\/\/[^\s"']+/g, '<URL>'],
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '<EMAIL>'],
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?/g, '<DATETIME>']
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?/g, '<DATETIME>'],
/*
A DDL download credits the user who asked for it and the moment it was
generated: a user id and a timestamp, neither of which the comparison is
about. Both are masked together because they sit in one comment.
*/
[/\/\* DDL generated by \S+ on\s+\S+ \*\//g, '/* DDL generated by <USER> on <DATETIME> */']
]
/* Keys whose value is a secret by definition, whatever the estate. Blank on the
+170 -5
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",
@@ -497,7 +521,7 @@
"table": "iwant",
"rows": [
{
"libds": "NO_SUCH_LIB.MPE_TABLES",
"libds": "NOLIB.MPE_TABLES",
"col": "RULE_TYPE",
"STARTROW": 1,
"ROWS": 5
@@ -508,7 +532,7 @@
"table": "iwant",
"rows": [
{
"libds": "NO_SUCH_LIB.MPE_TABLES",
"libds": "NOLIB.MPE_TABLES",
"col": "RULE_TYPE",
"STARTROW": 1,
"ROWS": 5
@@ -560,6 +584,86 @@
"note": "a temporal column formatted DATETIME. - no width in the name, so the format's own default (16) is the width, not the character fallback",
"accepted": []
},
{
"id": "mpe_tables",
"service": "public/getddl",
"format": "text",
"params": {
"ds": "MPE_TABLES",
"libref": "{{LIBREF}}",
"flavour": "SAS"
},
"mockParams": {
"ds": "MPE_TABLES",
"libref": "DC_JSLIB",
"flavour": "SAS"
},
"members": [
"text"
],
"note": "the DDL download for a control table: every column at the estate's own casing with its format, not-null and label, and the unique key mp_ds2ddl builds from dictionary.indexes",
"accepted": []
},
{
"id": "mpe_tables_pgsql",
"service": "public/getddl",
"format": "text",
"params": {
"ds": "MPE_TABLES",
"libref": "{{LIBREF}}",
"flavour": "PGSQL"
},
"mockParams": {
"ds": "MPE_TABLES",
"libref": "DC_JSLIB",
"flavour": "PGSQL"
},
"members": [
"text"
],
"note": "the PGSQL-flavoured DDL download: its own type names, quoting and key placement, from the same table metadata as the SAS flavour",
"accepted": []
},
{
"id": "mpe_tables_tsql",
"service": "public/getddl",
"format": "text",
"params": {
"ds": "MPE_TABLES",
"libref": "{{LIBREF}}",
"flavour": "TSQL"
},
"mockParams": {
"ds": "MPE_TABLES",
"libref": "DC_JSLIB",
"flavour": "TSQL"
},
"members": [
"text"
],
"note": "the TSQL-flavoured DDL download: its own type names, quoting and key placement, from the same table metadata as the SAS flavour",
"accepted": []
},
{
"id": "unknown_flavour",
"service": "public/getddl",
"format": "text",
"params": {
"ds": "MPE_TABLES",
"libref": "{{LIBREF}}",
"flavour": "NOSUCH"
},
"mockParams": {
"ds": "MPE_TABLES",
"libref": "DC_JSLIB",
"flavour": "NOSUCH"
},
"members": [
"text"
],
"note": "a flavour mp_ds2ddl has no branch for: the estate still streams a download, holding the generated-by comment and nothing else. The refusal that used to sit in the mock was invented, and this is what pins the absence",
"accepted": []
},
{
"id": "wrong_libref",
"service": "public/getcols",
@@ -568,7 +672,7 @@
"table": "iwant",
"rows": [
{
"LIBDS": "NO_SUCH_LIB.MPE_TABLES"
"LIBDS": "NOLIB.MPE_TABLES"
}
]
},
@@ -576,20 +680,81 @@
"table": "iwant",
"rows": [
{
"LIBDS": "NO_SUCH_LIB.MPE_TABLES"
"LIBDS": "NOLIB.MPE_TABLES"
}
]
},
"members": [
"sasjsAbort"
],
"note": "a column list for a library the estate does not have: the library is validated before the member is looked for, so this aborts instead of answering from another library's copy",
"note": "a column list for a well-formed library the estate does not have: the LIBDS passes the shape check and fails resolution, which is the estate's second abort",
"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 mp_abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
}
]
},
{
"id": "malformed_libds",
"service": "public/getcolvals",
"params": {},
"input": {
"table": "iwant",
"rows": [
{
"libds": "NO_SUCH_LIB.MPE_TABLES",
"col": "RULE_TYPE",
"STARTROW": 1,
"ROWS": 5
}
]
},
"mockInput": {
"table": "iwant",
"rows": [
{
"libds": "NO_SUCH_LIB.MPE_TABLES",
"col": "RULE_TYPE",
"STARTROW": 1,
"ROWS": 5
}
]
},
"members": [
"sasjsAbort"
],
"note": "an 11-character libref: the LIBDS shape rule rejects it before anything is assigned, so this is the service's own abort rather than the resolution one - the pair with public/getcolvals/wrong_libref, which uses a valid-length name",
"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 mp_abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
}
]
},
{
"id": "catalog_suffix_on_dataset",
"service": "public/getrawdata",
"params": {
"type": "CSV",
"table": "{{LIBREF}}.MPE_TABLES-FC",
"filter": "0"
},
"mockParams": {
"type": "CSV",
"table": "DC_JSLIB.MPE_TABLES-FC",
"filter": "0"
},
"members": [
"sasjsAbort"
],
"note": "a -FC suffix on a name that is not a format catalog: the estate reads the name through %mp_cntlout and blows up with syscc=1012, so the mock must not fall back to the dataset of the same name",
"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"
}
]
}
]
}
+30 -22
View File
@@ -98,39 +98,47 @@ test('a download carries a header row, drops the validity columns and keeps a ne
)
})
test('getcols refuses a library the estate does not have, instead of searching', () => {
test('getcols separates a malformed LIBDS from one it cannot resolve', () => {
const estate = seededEstate({ demo: false })
const MAC = '/Public/app/dc/services/public/getcols.sas'
// getcols.sas validates LIBDS before it reads: a library it cannot assign is
// "Invalid libds", even when the member exists in another library - the mock
// used to find MPE_TABLES in DC_JSLIB and answer for it
const wrongLib = JSON.parse(
// the shape check is getcols.sas:42, and it is the only place "Invalid libds"
// comes from: no member part, or a libref over eight characters
for (const libds of ['MPE_TABLES', 'NO_SUCH_LIB.MPE_TABLES']) {
const malformed = JSON.parse(
runService(estate, 'public/getcols', {
tableName: 'iwant',
rows: [{ LIBDS: libds }]
})
)
assert.deepEqual(
malformed.sasjsAbort,
[{ MSG: 'Invalid libds', MAC: MAC }],
libds + ' is malformed, not merely unknown'
)
}
/* A well-formed library the estate does not have passes the shape check and
fails resolution, which is the second abort - and today that abort is
syscc=3000 for every input it covers, a defect in getcols.sas rather than a
designed message, so the mock reproduces it as it is. */
const unknown = JSON.parse(
runService(estate, 'public/getcols', {
tableName: 'iwant',
rows: [{ LIBDS: 'NO_SUCH_LIB.MPE_TABLES' }]
rows: [{ LIBDS: 'NOLIB.MPE_TABLES' }]
})
)
assert.deepEqual(wrongLib.sasjsAbort, [
{
MSG: 'Invalid libds',
MAC: '/Public/app/dc/services/public/getcols.sas'
}
])
assert.equal(wrongLib.cols, undefined)
assert.deepEqual(unknown.sasjsAbort, [{ MSG: 'syscc=3000', MAC: MAC }])
assert.equal(unknown.cols, undefined)
// a LIBDS with no library part arrives at the same abort
const malformed = JSON.parse(
// a member missing from a library that does exist takes the same path
const missing = JSON.parse(
runService(estate, 'public/getcols', {
tableName: 'iwant',
rows: [{ LIBDS: 'MPE_TABLES' }]
rows: [{ LIBDS: 'DC_JSLIB.NO_SUCH_TABLE' }]
})
)
assert.deepEqual(malformed.sasjsAbort, [
{
MSG: 'Invalid libds',
MAC: '/Public/app/dc/services/public/getcols.sas'
}
])
assert.deepEqual(missing.sasjsAbort, [{ MSG: 'syscc=3000', MAC: MAC }])
// and the library the estate does have still answers
const found = JSON.parse(