@sasjs/cli 6.10.1 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -973,6 +973,16 @@ const resolveProgram = (program) => {
973
973
  */
974
974
  const SEEDED_HEADERS = 'content-type: text/html; charset=utf-8\n'
975
975
 
976
+ /**
977
+ * The largest body a single upload may carry.
978
+ *
979
+ * A browser worker holds the whole payload in memory, so an oversized upload is
980
+ * an out-of-memory failure rather than a limit a client can act on. This is the
981
+ * same 10 MB a deploy allows for one file, and it is checked from the File's
982
+ * `size` before the body is read.
983
+ */
984
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024
985
+
976
986
  const runProgram = ({ program, code, params = {}, tables = [] }) => {
977
987
  const payloadCode = code
978
988
  const codePath = program ? resolveProgram(program) : null
@@ -1163,7 +1173,9 @@ const readDriveFile = (filePath) => {
1163
1173
  String(filePath).replace(/^\/+/, '')
1164
1174
  )
1165
1175
  if (!FILES.has(abs)) {
1166
- const err = new Error('File not found')
1176
+ // the server's wording, because a client prints this: GET /drive/file
1177
+ // answers `res.status(404).send(err.message)`
1178
+ const err = new Error(`File doesn't exist.`)
1167
1179
  err.status = 404
1168
1180
  throw err
1169
1181
  }
@@ -1179,6 +1191,207 @@ const readDriveFile = (filePath) => {
1179
1191
  /** The stored base64 of a byte entry, once it is known to decode. */
1180
1192
  const validateEntry = (contents) => cleanedBase64(contents)
1181
1193
 
1194
+ // ------------------------------------------------------------- drive writes
1195
+ // The /drive endpoints answer the same contract sasjs/server serves, so a client
1196
+ // that uploads, renames or deletes needs no change for a mock build - only the
1197
+ // storage differs (a Map here, a folder on the server). Every mutation marks the
1198
+ // entry dirty, which is how the page learns to persist it: the worker can be
1199
+ // terminated between requests, so a write it did not hand back would live only
1200
+ // until the next reload.
1201
+
1202
+ const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
1203
+
1204
+ const driveError = (status, message) => {
1205
+ const error = new Error(message)
1206
+ error.status = status
1207
+ return error
1208
+ }
1209
+
1210
+ /**
1211
+ * A caller-supplied path as the absolute path under the drive root, or null when
1212
+ * it lands outside - the rule sasjs/server applies in resolveWithinDrive. A
1213
+ * leading slash means "under the drive root", never filesystem absolute, and the
1214
+ * test is on the whole normalised path rather than a substring: `/drive/filesx`
1215
+ * is not inside `/drive/files`.
1216
+ */
1217
+ const drivePath = (unsafePath) => {
1218
+ const root = DRIVE_FILES()
1219
+ const normalised = String(
1220
+ unsafePath === undefined || unsafePath === null ? '' : unsafePath
1221
+ )
1222
+ .replace(/\\/g, '/')
1223
+ .replace(/^\/+/, '')
1224
+ const resolved = P.resolve(root, normalised)
1225
+ return resolved === root || resolved.startsWith(root + '/') ? resolved : null
1226
+ }
1227
+
1228
+ /**
1229
+ * Writes one entry: bytes stay bytes (behind the base64 marker the drive reads
1230
+ * them back from), a string is text, and the entry is marked for persistence.
1231
+ *
1232
+ * An upload arrives as bytes and is stored as bytes, which is also what the
1233
+ * server does: multer writes the request body to disk and `saveFile` moves that
1234
+ * file, so an uploaded CSV is a byte file there too - a program reading it
1235
+ * without an encoding gets a Buffer on both hosts, and with `'utf8'` a string.
1236
+ * The difference a client actually sees is the content type a download is served
1237
+ * with, and that is decided by the extension on the GET route, as `sendFile`
1238
+ * does on the server.
1239
+ */
1240
+ const storeEntry = (abs, data) => {
1241
+ const bytes = asBytes(data)
1242
+ FILES.set(
1243
+ abs,
1244
+ bytes
1245
+ ? BINARY_PREFIX + bytesToBase64(bytes)
1246
+ : encodeText(String(data === undefined || data === null ? '' : data))
1247
+ )
1248
+ SIZES.delete(abs)
1249
+ markDirty(abs)
1250
+ }
1251
+
1252
+ /**
1253
+ * The ancestor of a path that is itself a file, if there is one. `mkdirp` would
1254
+ * happily turn `/data/x.csv` into a folder to hold `/data/x.csv/child`, leaving a
1255
+ * path that is a file and has children at the same time - a state the server
1256
+ * never reaches, because the filesystem refuses it (ENOTDIR).
1257
+ */
1258
+ const fileAncestorOf = (abs) => {
1259
+ const parts = abs.split('/').filter(Boolean)
1260
+ let acc = ''
1261
+ for (const part of parts.slice(0, -1)) {
1262
+ acc += '/' + part
1263
+ if (FILES.has(acc)) return acc
1264
+ }
1265
+ return null
1266
+ }
1267
+
1268
+ const saveDriveFile = (filePath, data) => {
1269
+ const abs = drivePath(filePath)
1270
+ if (!abs || abs === DRIVE_FILES())
1271
+ throw driveError(400, `Can't put file outside drive.`)
1272
+ // a folder at the same path would leave one path present as both, which the
1273
+ // server fails at the filesystem (EISDIR) rather than reporting as a conflict
1274
+ if (FILES.has(abs) || DIRS.has(abs))
1275
+ throw driveError(409, 'File already exists.')
1276
+ // the server fails this at the filesystem with ENOTDIR and answers a 500; a
1277
+ // named refusal is more use to a client than reproducing that, so it is a 400
1278
+ const ancestor = fileAncestorOf(abs)
1279
+ if (ancestor)
1280
+ // the drive-relative path, never the host's absolute one: a client has no
1281
+ // business knowing where the drive lives
1282
+ throw driveError(
1283
+ 400,
1284
+ `An ancestor of the path is a file: ${ancestor.slice(DRIVE_FILES().length) || '/'}`
1285
+ )
1286
+ mkdirp(P.dirname(abs))
1287
+ storeEntry(abs, data)
1288
+ return { status: 'success' }
1289
+ }
1290
+
1291
+ const updateDriveFile = (filePath, data) => {
1292
+ const abs = drivePath(filePath)
1293
+ if (!abs || abs === DRIVE_FILES())
1294
+ throw driveError(400, `Can't modify file outside drive.`)
1295
+ if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
1296
+ storeEntry(abs, data)
1297
+ return { status: 'success' }
1298
+ }
1299
+
1300
+ const removeDriveFile = (filePath) => {
1301
+ const abs = drivePath(filePath)
1302
+ if (!abs || abs === DRIVE_FILES())
1303
+ throw driveError(400, `Can't delete file outside drive.`)
1304
+ if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
1305
+ FILES.delete(abs)
1306
+ SIZES.delete(abs)
1307
+ markDirty(abs)
1308
+ return { status: 'success' }
1309
+ }
1310
+
1311
+ const addDriveFolder = (folderPath) => {
1312
+ const abs = drivePath(folderPath)
1313
+ if (!abs || abs === DRIVE_FILES())
1314
+ throw driveError(400, `Can't put folder outside drive.`)
1315
+ if (DIRS.has(abs) || FILES.has(abs))
1316
+ throw driveError(409, 'Folder already exists.')
1317
+ mkdirp(abs)
1318
+ return { status: 'success' }
1319
+ }
1320
+
1321
+ /**
1322
+ * Deletes a folder and everything under it. The drive is a flat map of paths, so
1323
+ * this is the entries' prefix rather than a directory walk - and each file it
1324
+ * removes is marked dirty (as a deletion) so the page drops it from IndexedDB
1325
+ * too. An empty folder is not represented in the persisted drive, which is a
1326
+ * map of files; see the note on the docs page.
1327
+ */
1328
+ const removeDriveFolder = (folderPath) => {
1329
+ const abs = drivePath(folderPath)
1330
+ if (!abs || abs === DRIVE_FILES())
1331
+ throw driveError(400, `Can't delete folder outside drive.`)
1332
+ if (!DIRS.has(abs)) throw driveError(404, `Folder doesn't exist.`)
1333
+ const prefix = abs + '/'
1334
+ for (const path of [...FILES.keys()]) {
1335
+ if (!path.startsWith(prefix)) continue
1336
+ FILES.delete(path)
1337
+ SIZES.delete(path)
1338
+ markDirty(path)
1339
+ }
1340
+ for (const dir of [...DIRS]) {
1341
+ if (dir === abs || dir.startsWith(prefix)) DIRS.delete(dir)
1342
+ }
1343
+ return { status: 'success' }
1344
+ }
1345
+
1346
+ /** Moves one stored file, leaving both paths for the page to persist. */
1347
+ const moveEntry = (from, to) => {
1348
+ FILES.set(to, FILES.get(from))
1349
+ FILES.delete(from)
1350
+ SIZES.delete(from)
1351
+ SIZES.delete(to)
1352
+ markDirty(to)
1353
+ markDirty(from)
1354
+ }
1355
+
1356
+ const renameDriveEntry = (oldPath, newPath) => {
1357
+ const from = drivePath(oldPath)
1358
+ if (!from || from === DRIVE_FILES())
1359
+ throw driveError(400, `Old path can't be outside of drive.`)
1360
+ const to = drivePath(newPath)
1361
+ if (!to || to === DRIVE_FILES())
1362
+ throw driveError(400, `New path can't be outside of drive.`)
1363
+
1364
+ if (DIRS.has(from)) {
1365
+ // the server fails this at the filesystem; naming it here says why
1366
+ if (to.startsWith(from + '/'))
1367
+ throw driveError(400, `New path can't be inside the folder being moved.`)
1368
+ if (DIRS.has(to) || FILES.has(to))
1369
+ throw driveError(409, 'Folder with new name already exists.')
1370
+ const prefix = from + '/'
1371
+ mkdirp(P.dirname(to))
1372
+ for (const dir of [...DIRS]) {
1373
+ if (dir === from || dir.startsWith(prefix)) {
1374
+ DIRS.delete(dir)
1375
+ mkdirp(to + dir.slice(from.length))
1376
+ }
1377
+ }
1378
+ for (const path of [...FILES.keys()]) {
1379
+ if (path.startsWith(prefix)) moveEntry(path, to + path.slice(from.length))
1380
+ }
1381
+ return { status: 'success' }
1382
+ }
1383
+
1384
+ if (FILES.has(from)) {
1385
+ if (FILES.has(to) || DIRS.has(to))
1386
+ throw driveError(409, 'File with new name already exists.')
1387
+ mkdirp(P.dirname(to))
1388
+ moveEntry(from, to)
1389
+ return { status: 'success' }
1390
+ }
1391
+
1392
+ throw driveError(404, 'No file/folder found for provided path.')
1393
+ }
1394
+
1182
1395
  const buildTree = () => {
1183
1396
  const walk = (dir) => {
1184
1397
  const node = {
@@ -1356,10 +1569,21 @@ const api = {
1356
1569
  run: (payload) => runProgram(payload),
1357
1570
  list: (folder) => listFolder(folder),
1358
1571
  read: (file) => readDriveFile(file),
1572
+ // the write half of the drive API: each one answers the server's success body
1573
+ // or throws an error carrying the server's status and message
1574
+ save: (filePath, data) => saveDriveFile(filePath, data),
1575
+ update: (filePath, data) => updateDriveFile(filePath, data),
1576
+ remove: (filePath) => removeDriveFile(filePath),
1577
+ mkdir: (folderPath) => addDriveFolder(folderPath),
1578
+ rmdir: (folderPath) => removeDriveFolder(folderPath),
1579
+ rename: (oldPath, newPath) => renameDriveEntry(oldPath, newPath),
1359
1580
  tree: () => buildTree(),
1360
1581
  // the content type a session's header file is seeded with, so the worker can
1361
1582
  // tell a default from a declaration rather than carrying a second literal
1362
1583
  HEADERS_SEED: SEEDED_HEADERS,
1584
+ // the largest single upload the host accepts, so the worker can refuse a body
1585
+ // before materialising it and the specs and docs read the same number
1586
+ MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES,
1363
1587
  // the format the snapshot download stamps, so persist.js writes the number
1364
1588
  // this runtime reads rather than a second literal
1365
1589
  DRIVE_FORMAT
@@ -11,12 +11,13 @@
11
11
  * receives the changed files back after every run.
12
12
  *
13
13
  * TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
14
- * path on the site's origin, runs whatever program the request names, and runs
15
- * the code a code/execute request carries - the same contract a SASjs server
16
- * offers, minus the server. Nothing here is a boundary: a page at this origin
17
- * can already do all of it, because a visitor's own browser is the client. So
18
- * a site holding anything private, or one served from a shared origin, belongs
19
- * behind a real server rather than a static host.
14
+ * path on the site's origin, writes to the drive when a drive request names a
15
+ * path, runs whatever program the request names, and runs the code a
16
+ * code/execute request carries - the same contract a SASjs server offers, minus
17
+ * the server. Nothing here is a boundary: a page at this origin can already do
18
+ * all of it, because a visitor's own browser is the client. So a site holding
19
+ * anything private, or one served from a shared origin, belongs behind a real
20
+ * server rather than a static host.
20
21
  */
21
22
  importScripts('/host/runtime-core.js')
22
23
 
@@ -173,6 +174,123 @@ const readPayload = async (request) => {
173
174
  return { params: body.params || {}, tables: body.tables || [], json: body }
174
175
  }
175
176
 
177
+ // ---------------------------------------------------------------- requests
178
+ /** A request body as JSON, and {} when it is not JSON - a client that posts a
179
+ * form where the route wants JSON gets the missing-field answer rather than a
180
+ * parse error. */
181
+ const readJson = async (request) => {
182
+ try {
183
+ return (await request.json()) || {}
184
+ } catch (e) {
185
+ return {}
186
+ }
187
+ }
188
+
189
+ const badRequest = (message) => {
190
+ const error = new Error(message)
191
+ error.status = 400
192
+ return error
193
+ }
194
+
195
+ const payloadTooLarge = (limit) => {
196
+ const error = new Error(
197
+ `The upload is larger than the browser host allows (${Math.round(
198
+ limit / (1024 * 1024)
199
+ )} MB).`
200
+ )
201
+ error.status = 413
202
+ return error
203
+ }
204
+
205
+ /**
206
+ * The value a route needs, or the 400 the server answers with when it is absent.
207
+ * The message is Joi's, because that is what a client sees from the real API.
208
+ */
209
+ const required = (value, name) => {
210
+ if (typeof value === 'string' && value.length) return value
211
+ throw badRequest(`"${name}" is required`)
212
+ }
213
+
214
+ /**
215
+ * sasjs/server refuses a file path with one of these extensions (filePathSchema
216
+ * in api/src/utils/validation.ts). Nothing is executable on a static host, but a
217
+ * client that would be refused on deploy should hear it here too.
218
+ */
219
+ const BLOCKED_FILE = /\.(exe|sh|htaccess)$/i
220
+
221
+ const fileTarget = (value, name) => {
222
+ const given = required(value, name)
223
+ if (BLOCKED_FILE.test(given)) throw badRequest('Invalid file extension')
224
+ return given
225
+ }
226
+
227
+ /** The bytes of an uploaded file, from the multipart field the API names. */
228
+ const uploadBytes = async (request) => {
229
+ const form = await request.formData().catch(() => null)
230
+ const upload = form && form.get('file')
231
+ if (!upload || typeof upload === 'string')
232
+ throw badRequest('"file" is not present.')
233
+ // refused from the File's size, before the body is read: a worker holds the
234
+ // whole payload in memory, so an oversized upload there is an out-of-memory
235
+ // failure rather than an answer a client can act on
236
+ if (typeof upload.size === 'number' && upload.size > R.MAX_UPLOAD_BYTES)
237
+ throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
238
+ const target = form.get('filePath')
239
+ return {
240
+ bytes: new Uint8Array(await upload.arrayBuffer()),
241
+ filePath: target === null ? undefined : target
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The content type `sendFile` would answer with for this path - by extension,
247
+ * because that is what express does, and because a byte entry would otherwise be
248
+ * served as `application/octet-stream` however text-like it is.
249
+ */
250
+ const CONTENT_TYPES = {
251
+ '.csv': 'text/csv',
252
+ '.json': 'application/json',
253
+ '.sas': 'text/plain',
254
+ '.txt': 'text/plain',
255
+ '.log': 'text/plain',
256
+ '.html': 'text/html',
257
+ '.htm': 'text/html',
258
+ '.js': 'text/javascript',
259
+ '.css': 'text/css',
260
+ '.zip': 'application/zip',
261
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
262
+ '.xls': 'application/vnd.ms-excel',
263
+ '.pdf': 'application/pdf',
264
+ '.png': 'image/png',
265
+ '.jpg': 'image/jpeg',
266
+ '.jpeg': 'image/jpeg',
267
+ '.gif': 'image/gif',
268
+ '.svg': 'image/svg+xml'
269
+ }
270
+
271
+ const contentTypeFor = (filePath, binary) => {
272
+ const name = String(filePath || '').toLowerCase()
273
+ const dot = name.lastIndexOf('.')
274
+ const known = dot > 0 ? CONTENT_TYPES[name.slice(dot)] : undefined
275
+ if (known) return known
276
+ return binary ? 'application/octet-stream' : 'text/plain'
277
+ }
278
+
279
+ /**
280
+ * Runs one drive mutation and then hands the page the entries it changed. The
281
+ * worker can be terminated between requests, so a write is only durable once the
282
+ * page has put it in IndexedDB.
283
+ *
284
+ * Await it at the call site: a bare `return driveResponse(...)` hands the
285
+ * promise back through handle's try without running its catch, so a refused
286
+ * write would reach the page as an unhandled rejection instead of its status.
287
+ */
288
+ const driveResponse = async (runner) => {
289
+ const result = runner()
290
+ await persistChanges()
291
+ return json(result)
292
+ }
293
+
176
294
  const handle = async (request, url) => {
177
295
  const path = url.pathname.replace(/\/+$/, '')
178
296
  const query = {}
@@ -192,20 +310,77 @@ const handle = async (request, url) => {
192
310
  }
193
311
 
194
312
  if (path.endsWith('/SASjsApi/drive/folder')) {
195
- return json(await R.list(query._folderPath || ''))
313
+ if (request.method === 'GET')
314
+ return json(await R.list(query._folderPath || ''))
315
+
316
+ if (request.method === 'POST') {
317
+ const body = await readJson(request)
318
+ return await driveResponse(() =>
319
+ R.mkdir(required(body.folderPath, 'folderPath'))
320
+ )
321
+ }
322
+
323
+ if (request.method === 'DELETE') {
324
+ // an empty path names the drive root, which the server refuses at
325
+ // validation rather than deleting everything
326
+ const folderPath = required(query._folderPath, '_folderPath')
327
+ return await driveResponse(() => R.rmdir(folderPath))
328
+ }
196
329
  }
197
330
 
198
331
  if (path.endsWith('/SASjsApi/drive/filetree')) {
199
332
  return json(R.tree())
200
333
  }
201
334
 
202
- if (path.endsWith('/SASjsApi/drive/file') && request.method === 'GET') {
203
- const file = R.read(query._filePath || '')
204
- return file.binary
205
- ? new Response(toBytes(file.contents), {
206
- headers: { 'content-type': 'application/octet-stream' }
207
- })
208
- : text(file.contents)
335
+ if (path.endsWith('/SASjsApi/drive/rename')) {
336
+ if (request.method === 'POST') {
337
+ const body = await readJson(request)
338
+ // both ends go through the extension check: without it a file could be
339
+ // renamed into a blocked name and then be unreachable by DELETE and PATCH,
340
+ // which apply the same check to the path they are given
341
+ const oldPath = fileTarget(body.oldPath, 'oldPath')
342
+ const newPath = fileTarget(body.newPath, 'newPath')
343
+ return await driveResponse(() => R.rename(oldPath, newPath))
344
+ }
345
+ }
346
+
347
+ if (path.endsWith('/SASjsApi/drive/file')) {
348
+ if (request.method === 'GET') {
349
+ const file = R.read(query._filePath || '')
350
+ // the server answers this with `sendFile`, so the type comes from the
351
+ // extension: a .csv is text/csv whether it was uploaded as bytes or
352
+ // written by a service as text
353
+ return new Response(
354
+ file.binary ? toBytes(file.contents) : file.contents,
355
+ {
356
+ status: 200,
357
+ headers: {
358
+ 'content-type': contentTypeFor(query._filePath, file.binary)
359
+ }
360
+ }
361
+ )
362
+ }
363
+
364
+ if (request.method === 'DELETE') {
365
+ const filePath = fileTarget(query._filePath, '_filePath')
366
+ return await driveResponse(() => R.remove(filePath))
367
+ }
368
+
369
+ // POST creates (409 when the path is taken) and PATCH replaces (404 when
370
+ // it is not there) - the split the server's routes make, so a client that
371
+ // keys on the status code behaves the same way
372
+ if (request.method === 'POST' || request.method === 'PATCH') {
373
+ const upload = await uploadBytes(request)
374
+ const filePath = fileTarget(
375
+ query._filePath || upload.filePath,
376
+ '_filePath'
377
+ )
378
+ return await driveResponse(() =>
379
+ request.method === 'PATCH'
380
+ ? R.update(filePath, upload.bytes)
381
+ : R.save(filePath, upload.bytes)
382
+ )
383
+ }
209
384
  }
210
385
 
211
386
  if (
@@ -252,6 +427,12 @@ const handle = async (request, url) => {
252
427
  } catch (e) {
253
428
  const message = (e && e.stack) || String(e)
254
429
  broadcast({ type: 'sasjs-host-error', path, message })
430
+ // A drive refusal is the message itself, in plain text: the server answers
431
+ // these with `res.status(code).send(err.message)`, and its own suite asserts
432
+ // `res.text`. The other routes answer JSON, because a program failure
433
+ // carries more than a message (`status`, `error`), so they keep the envelope.
434
+ if (path.includes('/SASjsApi/drive/') && e && e.status)
435
+ return text((e && e.message) || String(e), e.status)
255
436
  return json(
256
437
  { message: (e && e.message) || String(e) },
257
438
  (e && e.status) || 500
@@ -949,4 +949,249 @@ describe('browser runtime', function () {
949
949
  var runtime = loadRuntime();
950
950
  expect(function () { return runtime.run({ program: 'services/public/missing' }); }).toThrow(/Program not found in drive/);
951
951
  });
952
+ describe('drive writes', function () {
953
+ // the write API speaks drive-relative paths, and the seed holds absolute
954
+ // ones, so both sides go through the same root here
955
+ var root = '/sasjs/drive/files';
956
+ var seededRuntime = function () { return __awaiter(void 0, void 0, void 0, function () {
957
+ var runtime;
958
+ var _a;
959
+ return __generator(this, function (_b) {
960
+ switch (_b.label) {
961
+ case 0:
962
+ runtime = loadRuntime();
963
+ return [4 /*yield*/, seedDrive(runtime, (_a = {},
964
+ _a["".concat(root, "/data/keep.csv")] = 'a,b\n1,2\n',
965
+ _a))
966
+ // boot marks the whole drive dirty, so the page persists a complete
967
+ // snapshot; clearing it here leaves each test with only its own writes
968
+ ];
969
+ case 1:
970
+ _b.sent();
971
+ // boot marks the whole drive dirty, so the page persists a complete
972
+ // snapshot; clearing it here leaves each test with only its own writes
973
+ runtime.takeDirty();
974
+ return [2 /*return*/, runtime];
975
+ }
976
+ });
977
+ }); };
978
+ var status = function (fn) {
979
+ try {
980
+ fn();
981
+ }
982
+ catch (e) {
983
+ return e.status;
984
+ }
985
+ return 200;
986
+ };
987
+ it('creates a file, and the page is told to persist it', function () { return __awaiter(void 0, void 0, void 0, function () {
988
+ var runtime;
989
+ var _a;
990
+ return __generator(this, function (_b) {
991
+ switch (_b.label) {
992
+ case 0: return [4 /*yield*/, seededRuntime()];
993
+ case 1:
994
+ runtime = _b.sent();
995
+ expect(runtime.save('/data/new/deep/up.csv', 'x,y\n')).toEqual({
996
+ status: 'success'
997
+ });
998
+ // the write is what the page receives after the request: a worker is
999
+ // terminated between requests, so without this the upload is gone
1000
+ expect(runtime.takeDirty()).toEqual((_a = {},
1001
+ _a["".concat(root, "/data/new/deep/up.csv")] = 'x,y\n',
1002
+ _a));
1003
+ expect(runtime.read('/data/new/deep/up.csv')).toEqual({
1004
+ contents: 'x,y\n',
1005
+ binary: false
1006
+ });
1007
+ return [2 /*return*/];
1008
+ }
1009
+ });
1010
+ }); });
1011
+ it('stores an upload as bytes, the way the server stores one', function () { return __awaiter(void 0, void 0, void 0, function () {
1012
+ var runtime;
1013
+ return __generator(this, function (_a) {
1014
+ switch (_a.label) {
1015
+ case 0: return [4 /*yield*/, seededRuntime()];
1016
+ case 1:
1017
+ runtime = _a.sent();
1018
+ runtime.save('/data/pack.zip', Uint8Array.from([80, 75, 3, 4]));
1019
+ expect(runtime.read('/data/pack.zip')).toEqual({
1020
+ contents: 'UEsDBA==',
1021
+ binary: true
1022
+ });
1023
+ // and a service reading it gets a Buffer, as it does on the server
1024
+ expect(runtime.run({
1025
+ code: "_webout = typeof require('fs').readFileSync('".concat(root, "/data/pack.zip');")
1026
+ }).webout).toBe('object');
1027
+ expect(runtime.run({
1028
+ code: "_webout = require('fs').readFileSync('".concat(root, "/data/pack.zip', 'base64');")
1029
+ }).webout).toBe('UEsDBA==');
1030
+ return [2 /*return*/];
1031
+ }
1032
+ });
1033
+ }); });
1034
+ it('refuses to create a file over one that exists', function () { return __awaiter(void 0, void 0, void 0, function () {
1035
+ var runtime;
1036
+ return __generator(this, function (_a) {
1037
+ switch (_a.label) {
1038
+ case 0: return [4 /*yield*/, seededRuntime()];
1039
+ case 1:
1040
+ runtime = _a.sent();
1041
+ expect(status(function () { return runtime.save('/data/keep.csv', 'x'); })).toBe(409);
1042
+ return [2 /*return*/];
1043
+ }
1044
+ });
1045
+ }); });
1046
+ it('replaces a file, and refuses one that is not there', function () { return __awaiter(void 0, void 0, void 0, function () {
1047
+ var runtime;
1048
+ return __generator(this, function (_a) {
1049
+ switch (_a.label) {
1050
+ case 0: return [4 /*yield*/, seededRuntime()];
1051
+ case 1:
1052
+ runtime = _a.sent();
1053
+ expect(runtime.update('/data/keep.csv', 'replaced')).toEqual({
1054
+ status: 'success'
1055
+ });
1056
+ expect(runtime.read('/data/keep.csv').contents).toBe('replaced');
1057
+ expect(status(function () { return runtime.update('/data/missing.csv', 'x'); })).toBe(404);
1058
+ return [2 /*return*/];
1059
+ }
1060
+ });
1061
+ }); });
1062
+ it('deletes a file, and reports a deletion to the page', function () { return __awaiter(void 0, void 0, void 0, function () {
1063
+ var runtime;
1064
+ var _a;
1065
+ return __generator(this, function (_b) {
1066
+ switch (_b.label) {
1067
+ case 0: return [4 /*yield*/, seededRuntime()];
1068
+ case 1:
1069
+ runtime = _b.sent();
1070
+ expect(runtime.remove('/data/keep.csv')).toEqual({ status: 'success' });
1071
+ expect(runtime.takeDirty()).toEqual((_a = {},
1072
+ _a["".concat(root, "/data/keep.csv")] = null,
1073
+ _a));
1074
+ expect(status(function () { return runtime.read('/data/keep.csv'); })).toBe(404);
1075
+ expect(status(function () { return runtime.remove('/data/keep.csv'); })).toBe(404);
1076
+ return [2 /*return*/];
1077
+ }
1078
+ });
1079
+ }); });
1080
+ it('creates a folder, and refuses one that already exists', function () { return __awaiter(void 0, void 0, void 0, function () {
1081
+ var runtime;
1082
+ return __generator(this, function (_a) {
1083
+ switch (_a.label) {
1084
+ case 0: return [4 /*yield*/, seededRuntime()];
1085
+ case 1:
1086
+ runtime = _a.sent();
1087
+ expect(runtime.mkdir('/data/reports')).toEqual({ status: 'success' });
1088
+ expect(runtime.list('/data').folders).toContain('reports');
1089
+ expect(status(function () { return runtime.mkdir('/data/reports'); })).toBe(409);
1090
+ return [2 /*return*/];
1091
+ }
1092
+ });
1093
+ }); });
1094
+ it('deletes a folder with everything under it', function () { return __awaiter(void 0, void 0, void 0, function () {
1095
+ var runtime, cleared;
1096
+ return __generator(this, function (_a) {
1097
+ switch (_a.label) {
1098
+ case 0: return [4 /*yield*/, seededRuntime()];
1099
+ case 1:
1100
+ runtime = _a.sent();
1101
+ runtime.mkdir('/data/reports');
1102
+ runtime.save('/data/reports/a.csv', 'a');
1103
+ runtime.save('/data/reports/nested/b.csv', 'b');
1104
+ expect(runtime.rmdir('/data/reports')).toEqual({ status: 'success' });
1105
+ expect(runtime.list('/data').folders).not.toContain('reports');
1106
+ expect(status(function () { return runtime.read('/data/reports/a.csv'); })).toBe(404);
1107
+ expect(status(function () { return runtime.read('/data/reports/nested/b.csv'); })).toBe(404);
1108
+ cleared = runtime.takeDirty();
1109
+ expect(cleared["".concat(root, "/data/reports/a.csv")]).toBeNull();
1110
+ expect(cleared["".concat(root, "/data/reports/nested/b.csv")]).toBeNull();
1111
+ return [2 /*return*/];
1112
+ }
1113
+ });
1114
+ }); });
1115
+ it('renames a file, and moves a folder with its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
1116
+ var runtime;
1117
+ return __generator(this, function (_a) {
1118
+ switch (_a.label) {
1119
+ case 0: return [4 /*yield*/, seededRuntime()];
1120
+ case 1:
1121
+ runtime = _a.sent();
1122
+ runtime.mkdir('/data/reports');
1123
+ runtime.save('/data/reports/a.csv', 'a');
1124
+ expect(runtime.rename('/data/keep.csv', '/data/kept.csv')).toEqual({
1125
+ status: 'success'
1126
+ });
1127
+ expect(runtime.read('/data/kept.csv').contents).toBe('a,b\n1,2\n');
1128
+ expect(status(function () { return runtime.read('/data/keep.csv'); })).toBe(404);
1129
+ expect(runtime.rename('/data/reports', '/archive')).toEqual({
1130
+ status: 'success'
1131
+ });
1132
+ expect(runtime.read('/archive/a.csv').contents).toBe('a');
1133
+ expect(status(function () { return runtime.read('/data/reports/a.csv'); })).toBe(404);
1134
+ expect(runtime.list('/archive').files).toEqual(['a.csv']);
1135
+ return [2 /*return*/];
1136
+ }
1137
+ });
1138
+ }); });
1139
+ it('refuses a rename onto something that exists', function () { return __awaiter(void 0, void 0, void 0, function () {
1140
+ var runtime;
1141
+ return __generator(this, function (_a) {
1142
+ switch (_a.label) {
1143
+ case 0: return [4 /*yield*/, seededRuntime()];
1144
+ case 1:
1145
+ runtime = _a.sent();
1146
+ runtime.save('/data/other.csv', 'o');
1147
+ expect(status(function () { return runtime.rename('/data/keep.csv', '/data/other.csv'); })).toBe(409);
1148
+ expect(status(function () { return runtime.rename('/data/nope.csv', '/data/x.csv'); })).toBe(404);
1149
+ return [2 /*return*/];
1150
+ }
1151
+ });
1152
+ }); });
1153
+ it('refuses any path that lands outside the drive', function () { return __awaiter(void 0, void 0, void 0, function () {
1154
+ var runtime;
1155
+ return __generator(this, function (_a) {
1156
+ switch (_a.label) {
1157
+ case 0: return [4 /*yield*/, seededRuntime()
1158
+ // a leading slash is drive-relative, and '..' is resolved before the
1159
+ // containment test - the rule the server applies in resolveWithinDrive
1160
+ ];
1161
+ case 1:
1162
+ runtime = _a.sent();
1163
+ // a leading slash is drive-relative, and '..' is resolved before the
1164
+ // containment test - the rule the server applies in resolveWithinDrive
1165
+ expect(status(function () { return runtime.save('/../escape.csv', 'x'); })).toBe(400);
1166
+ expect(status(function () { return runtime.save('/data/../../escape.csv', 'x'); })).toBe(400);
1167
+ expect(status(function () { return runtime.remove('/../../etc/passwd'); })).toBe(400);
1168
+ expect(status(function () { return runtime.rename('/../x', '/data/y'); })).toBe(400);
1169
+ expect(status(function () { return runtime.rename('/data/keep.csv', '/../y'); })).toBe(400);
1170
+ // a file whose NAME starts with dots is not a traversal
1171
+ expect(runtime.save('/data/..weird.csv', 'x')).toEqual({
1172
+ status: 'success'
1173
+ });
1174
+ return [2 /*return*/];
1175
+ }
1176
+ });
1177
+ }); });
1178
+ it('keeps the bytes of an entry it moves', function () { return __awaiter(void 0, void 0, void 0, function () {
1179
+ var runtime;
1180
+ return __generator(this, function (_a) {
1181
+ switch (_a.label) {
1182
+ case 0: return [4 /*yield*/, seededRuntime()];
1183
+ case 1:
1184
+ runtime = _a.sent();
1185
+ runtime.save('/data/pack.zip', Uint8Array.from([1, 2, 3]));
1186
+ runtime.rename('/data/pack.zip', '/data/moved.zip');
1187
+ expect(runtime.read('/data/moved.zip')).toEqual({
1188
+ contents: 'AQID',
1189
+ binary: true
1190
+ });
1191
+ expect(runtime.stats().files).toBe(2);
1192
+ return [2 /*return*/];
1193
+ }
1194
+ });
1195
+ }); });
1196
+ });
952
1197
  });
@@ -514,9 +514,9 @@ describe('service worker host', function () {
514
514
  response = _b.sent();
515
515
  expect(response.status).toBe(404);
516
516
  _a = expect;
517
- return [4 /*yield*/, response.json()];
517
+ return [4 /*yield*/, response.text()];
518
518
  case 2:
519
- _a.apply(void 0, [(_b.sent()).message]).toBe('File not found');
519
+ _a.apply(void 0, [_b.sent()]).toBe("File doesn't exist.");
520
520
  return [2 /*return*/];
521
521
  }
522
522
  });
@@ -567,9 +567,9 @@ describe('service worker host', function () {
567
567
  response = _c.sent();
568
568
  expect(response.status).toBe(500);
569
569
  _a = expect;
570
- return [4 /*yield*/, response.json()];
570
+ return [4 /*yield*/, response.text()];
571
571
  case 2:
572
- _a.apply(void 0, [(_c.sent()).message]).toContain('Corrupt binary entry');
572
+ _a.apply(void 0, [_c.sent()]).toContain('Corrupt binary entry');
573
573
  return [2 /*return*/];
574
574
  }
575
575
  });
@@ -799,4 +799,490 @@ describe('service worker host', function () {
799
799
  var dispatch = seeded().dispatch;
800
800
  expect(dispatch('/index.html')).toBeUndefined();
801
801
  });
802
+ describe('drive writes', function () {
803
+ /**
804
+ * The response as a client sees it. A drive refusal is plain text (the
805
+ * server sends `res.status(code).send(err.message)`), a success is JSON.
806
+ */
807
+ var answer = function (response) { return __awaiter(void 0, void 0, void 0, function () {
808
+ var resolved, type, _a;
809
+ var _b;
810
+ return __generator(this, function (_c) {
811
+ switch (_c.label) {
812
+ case 0: return [4 /*yield*/, response];
813
+ case 1:
814
+ resolved = _c.sent();
815
+ type = resolved.headers.get('content-type') || '';
816
+ _b = {
817
+ status: resolved.status
818
+ };
819
+ if (!type.includes('application/json')) return [3 /*break*/, 3];
820
+ return [4 /*yield*/, resolved.json()];
821
+ case 2:
822
+ _a = _c.sent();
823
+ return [3 /*break*/, 5];
824
+ case 3: return [4 /*yield*/, resolved.text()];
825
+ case 4:
826
+ _a = _c.sent();
827
+ _c.label = 5;
828
+ case 5: return [2 /*return*/, (_b.body = _a,
829
+ _b)];
830
+ }
831
+ });
832
+ }); };
833
+ it('answers a drive refusal as text, the way the server sends one', function () { return __awaiter(void 0, void 0, void 0, function () {
834
+ var dispatch, response, _a;
835
+ return __generator(this, function (_b) {
836
+ switch (_b.label) {
837
+ case 0:
838
+ dispatch = seeded().dispatch;
839
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
840
+ encodeURIComponent('/data/missing.csv'))
841
+ // the server's drive routes answer `res.status(code).send(err.message)`,
842
+ // and its own suite asserts `res.text` - so a client that reads the body
843
+ // as text must get the message here too, not a JSON envelope
844
+ ];
845
+ case 1:
846
+ response = _b.sent();
847
+ // the server's drive routes answer `res.status(code).send(err.message)`,
848
+ // and its own suite asserts `res.text` - so a client that reads the body
849
+ // as text must get the message here too, not a JSON envelope
850
+ expect(response.status).toBe(404);
851
+ expect(response.headers.get('content-type')).toContain('text/plain');
852
+ _a = expect;
853
+ return [4 /*yield*/, response.text()];
854
+ case 2:
855
+ _a.apply(void 0, [_b.sent()]).toBe("File doesn't exist.");
856
+ return [2 /*return*/];
857
+ }
858
+ });
859
+ }); });
860
+ it('creates a file, which the drive then serves', function () { return __awaiter(void 0, void 0, void 0, function () {
861
+ var dispatch, created, read, _a;
862
+ return __generator(this, function (_b) {
863
+ switch (_b.label) {
864
+ case 0:
865
+ dispatch = seeded().dispatch;
866
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
867
+ encodeURIComponent('/data/users.csv'), (function () {
868
+ var form = new FormData();
869
+ form.set('file', new Blob(['id,name\n1,Ann\n']), 'users.csv');
870
+ return { method: 'POST', body: form };
871
+ })()))];
872
+ case 1:
873
+ created = _b.sent();
874
+ expect(created).toEqual({ status: 200, body: { status: 'success' } });
875
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
876
+ encodeURIComponent('/data/users.csv'))];
877
+ case 2:
878
+ read = _b.sent();
879
+ _a = expect;
880
+ return [4 /*yield*/, read.text()];
881
+ case 3:
882
+ _a.apply(void 0, [_b.sent()]).toBe('id,name\n1,Ann\n');
883
+ return [2 /*return*/];
884
+ }
885
+ });
886
+ }); });
887
+ it('creates a file at the path in the form when the query has none', function () { return __awaiter(void 0, void 0, void 0, function () {
888
+ var dispatch, form, created, read, _a;
889
+ return __generator(this, function (_b) {
890
+ switch (_b.label) {
891
+ case 0:
892
+ dispatch = seeded().dispatch;
893
+ form = new FormData();
894
+ form.set('file', new Blob(['x']), 'x.txt');
895
+ form.set('filePath', '/data/from-form.txt');
896
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file', { method: 'POST', body: form }))];
897
+ case 1:
898
+ created = _b.sent();
899
+ expect(created.body).toEqual({ status: 'success' });
900
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
901
+ encodeURIComponent('/data/from-form.txt'))];
902
+ case 2:
903
+ read = _b.sent();
904
+ _a = expect;
905
+ return [4 /*yield*/, read.text()];
906
+ case 3:
907
+ _a.apply(void 0, [_b.sent()]).toBe('x');
908
+ return [2 /*return*/];
909
+ }
910
+ });
911
+ }); });
912
+ it('answers 409 for a create over an existing file, 404 for a replace of a missing one', function () { return __awaiter(void 0, void 0, void 0, function () {
913
+ var dispatch, form, again, missing;
914
+ return __generator(this, function (_a) {
915
+ switch (_a.label) {
916
+ case 0:
917
+ dispatch = seeded().dispatch;
918
+ form = function () {
919
+ var f = new FormData();
920
+ f.set('file', new Blob(['x']), 'x.csv');
921
+ return f;
922
+ };
923
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/x.csv'), { method: 'POST', body: form() })];
924
+ case 1:
925
+ _a.sent();
926
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/x.csv'), { method: 'POST', body: form() }))];
927
+ case 2:
928
+ again = _a.sent();
929
+ expect(again.status).toBe(409);
930
+ expect(again.body).toEqual('File already exists.');
931
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
932
+ encodeURIComponent('/data/missing.csv'), { method: 'PATCH', body: form() }))];
933
+ case 3:
934
+ missing = _a.sent();
935
+ expect(missing.status).toBe(404);
936
+ expect(missing.body).toEqual("File doesn't exist.");
937
+ return [2 /*return*/];
938
+ }
939
+ });
940
+ }); });
941
+ it('replaces a file and deletes one', function () { return __awaiter(void 0, void 0, void 0, function () {
942
+ var dispatch, form, replacement, replaced, read, _a, removed, gone;
943
+ return __generator(this, function (_b) {
944
+ switch (_b.label) {
945
+ case 0:
946
+ dispatch = seeded().dispatch;
947
+ form = new FormData();
948
+ form.set('file', new Blob(['v1']), 'v.csv');
949
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/v.csv'), { method: 'POST', body: form })];
950
+ case 1:
951
+ _b.sent();
952
+ replacement = new FormData();
953
+ replacement.set('file', new Blob(['v2']), 'v.csv');
954
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/v.csv'), { method: 'PATCH', body: replacement }))];
955
+ case 2:
956
+ replaced = _b.sent();
957
+ expect(replaced.body).toEqual({ status: 'success' });
958
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/v.csv'))];
959
+ case 3:
960
+ read = _b.sent();
961
+ _a = expect;
962
+ return [4 /*yield*/, read.text()];
963
+ case 4:
964
+ _a.apply(void 0, [_b.sent()]).toBe('v2');
965
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/v.csv'), { method: 'DELETE' }))];
966
+ case 5:
967
+ removed = _b.sent();
968
+ expect(removed.body).toEqual({ status: 'success' });
969
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/v.csv'))];
970
+ case 6:
971
+ gone = _b.sent();
972
+ expect(gone.status).toBe(404);
973
+ return [2 /*return*/];
974
+ }
975
+ });
976
+ }); });
977
+ it('refuses an upload with no file, no path, or a blocked extension', function () { return __awaiter(void 0, void 0, void 0, function () {
978
+ var dispatch, empty, noFile, form, noPath, sh, blocked;
979
+ return __generator(this, function (_a) {
980
+ switch (_a.label) {
981
+ case 0:
982
+ dispatch = seeded().dispatch;
983
+ empty = new FormData();
984
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/x.csv'), { method: 'POST', body: empty }))];
985
+ case 1:
986
+ noFile = _a.sent();
987
+ expect(noFile.status).toBe(400);
988
+ expect(noFile.body).toEqual('"file" is not present.');
989
+ form = new FormData();
990
+ form.set('file', new Blob(['x']), 'x.csv');
991
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file', { method: 'POST', body: form }))];
992
+ case 2:
993
+ noPath = _a.sent();
994
+ expect(noPath.status).toBe(400);
995
+ expect(noPath.body).toEqual('"_filePath" is required');
996
+ sh = new FormData();
997
+ sh.set('file', new Blob(['x']), 'run.sh');
998
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
999
+ encodeURIComponent('/data/run.sh'), { method: 'POST', body: sh }))];
1000
+ case 3:
1001
+ blocked = _a.sent();
1002
+ expect(blocked.status).toBe(400);
1003
+ expect(blocked.body).toEqual('Invalid file extension');
1004
+ return [2 /*return*/];
1005
+ }
1006
+ });
1007
+ }); });
1008
+ it('refuses a path outside the drive on every write route', function () { return __awaiter(void 0, void 0, void 0, function () {
1009
+ var dispatch, form, outside, rename;
1010
+ return __generator(this, function (_a) {
1011
+ switch (_a.label) {
1012
+ case 0:
1013
+ dispatch = seeded().dispatch;
1014
+ form = new FormData();
1015
+ form.set('file', new Blob(['x']), 'x.csv');
1016
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
1017
+ encodeURIComponent('/../escape.csv'), { method: 'POST', body: form }))];
1018
+ case 1:
1019
+ outside = _a.sent();
1020
+ expect(outside.status).toBe(400);
1021
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/rename', {
1022
+ method: 'POST',
1023
+ headers: { 'content-type': 'application/json' },
1024
+ body: JSON.stringify({ oldPath: '/../x', newPath: '/data/y' })
1025
+ }))];
1026
+ case 2:
1027
+ rename = _a.sent();
1028
+ expect(rename.status).toBe(400);
1029
+ return [2 /*return*/];
1030
+ }
1031
+ });
1032
+ }); });
1033
+ it('creates a folder, lists it, and deletes it with its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
1034
+ var dispatch, made, form, listed, _a, removed, gone;
1035
+ return __generator(this, function (_b) {
1036
+ switch (_b.label) {
1037
+ case 0:
1038
+ dispatch = seeded().dispatch;
1039
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/folder', {
1040
+ method: 'POST',
1041
+ headers: { 'content-type': 'application/json' },
1042
+ body: JSON.stringify({ folderPath: '/data/reports' })
1043
+ }))];
1044
+ case 1:
1045
+ made = _b.sent();
1046
+ expect(made.body).toEqual({ status: 'success' });
1047
+ form = new FormData();
1048
+ form.set('file', new Blob(['a']), 'a.csv');
1049
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
1050
+ encodeURIComponent('/data/reports/a.csv'), { method: 'POST', body: form })];
1051
+ case 2:
1052
+ _b.sent();
1053
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/folder?_folderPath=' + encodeURIComponent('/data'))];
1054
+ case 3:
1055
+ listed = _b.sent();
1056
+ _a = expect;
1057
+ return [4 /*yield*/, listed.json()];
1058
+ case 4:
1059
+ _a.apply(void 0, [_b.sent()]).toEqual({
1060
+ files: [],
1061
+ folders: ['reports']
1062
+ });
1063
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/folder?_folderPath=' +
1064
+ encodeURIComponent('/data/reports'), { method: 'DELETE' }))];
1065
+ case 5:
1066
+ removed = _b.sent();
1067
+ expect(removed.body).toEqual({ status: 'success' });
1068
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
1069
+ encodeURIComponent('/data/reports/a.csv'))];
1070
+ case 6:
1071
+ gone = _b.sent();
1072
+ expect(gone.status).toBe(404);
1073
+ return [2 /*return*/];
1074
+ }
1075
+ });
1076
+ }); });
1077
+ it('refuses a folder delete with no path', function () { return __awaiter(void 0, void 0, void 0, function () {
1078
+ var dispatch, removed;
1079
+ return __generator(this, function (_a) {
1080
+ switch (_a.label) {
1081
+ case 0:
1082
+ dispatch = seeded().dispatch;
1083
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/folder', { method: 'DELETE' }))];
1084
+ case 1:
1085
+ removed = _a.sent();
1086
+ expect(removed.status).toBe(400);
1087
+ expect(removed.body).toEqual('"_folderPath" is required');
1088
+ return [2 /*return*/];
1089
+ }
1090
+ });
1091
+ }); });
1092
+ it('refuses a rename that would produce a blocked extension', function () { return __awaiter(void 0, void 0, void 0, function () {
1093
+ var dispatch, form, toBlocked, fromBlocked;
1094
+ return __generator(this, function (_a) {
1095
+ switch (_a.label) {
1096
+ case 0:
1097
+ dispatch = seeded().dispatch;
1098
+ form = new FormData();
1099
+ form.set('file', new Blob(['r']), 'r.csv');
1100
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/old.csv'), { method: 'POST', body: form })];
1101
+ case 1:
1102
+ _a.sent();
1103
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/rename', {
1104
+ method: 'POST',
1105
+ headers: { 'content-type': 'application/json' },
1106
+ body: JSON.stringify({
1107
+ oldPath: '/data/old.csv',
1108
+ newPath: '/data/x.sh'
1109
+ })
1110
+ }))
1111
+ // DELETE and PATCH run the same check on the path they are given, so a file
1112
+ // renamed into a blocked name could not be removed or replaced afterwards
1113
+ ];
1114
+ case 2:
1115
+ toBlocked = _a.sent();
1116
+ // DELETE and PATCH run the same check on the path they are given, so a file
1117
+ // renamed into a blocked name could not be removed or replaced afterwards
1118
+ expect(toBlocked.status).toBe(400);
1119
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/rename', {
1120
+ method: 'POST',
1121
+ headers: { 'content-type': 'application/json' },
1122
+ body: JSON.stringify({
1123
+ oldPath: '/data/x.sh',
1124
+ newPath: '/data/new.csv'
1125
+ })
1126
+ }))];
1127
+ case 3:
1128
+ fromBlocked = _a.sent();
1129
+ expect(fromBlocked.status).toBe(400);
1130
+ return [2 /*return*/];
1131
+ }
1132
+ });
1133
+ }); });
1134
+ it('refuses a file where a folder already holds the path', function () { return __awaiter(void 0, void 0, void 0, function () {
1135
+ var dispatch, form, atFolder;
1136
+ return __generator(this, function (_a) {
1137
+ switch (_a.label) {
1138
+ case 0:
1139
+ dispatch = seeded().dispatch;
1140
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/folder', {
1141
+ method: 'POST',
1142
+ headers: { 'content-type': 'application/json' },
1143
+ body: JSON.stringify({ folderPath: '/data/reports' })
1144
+ })];
1145
+ case 1:
1146
+ _a.sent();
1147
+ form = new FormData();
1148
+ form.set('file', new Blob(['r']), 'r.csv');
1149
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
1150
+ encodeURIComponent('/data/reports'), { method: 'POST', body: form }))
1151
+ // the server fails this at the filesystem (EISDIR); naming it as a conflict
1152
+ // keeps the drive from holding one path as both a file and a folder
1153
+ ];
1154
+ case 2:
1155
+ atFolder = _a.sent();
1156
+ // the server fails this at the filesystem (EISDIR); naming it as a conflict
1157
+ // keeps the drive from holding one path as both a file and a folder
1158
+ expect(atFolder.status).toBe(409);
1159
+ return [2 /*return*/];
1160
+ }
1161
+ });
1162
+ }); });
1163
+ it('refuses a file under a path that is already a file', function () { return __awaiter(void 0, void 0, void 0, function () {
1164
+ var dispatch, form, child, under, stillAFile, _a;
1165
+ return __generator(this, function (_b) {
1166
+ switch (_b.label) {
1167
+ case 0:
1168
+ dispatch = seeded().dispatch;
1169
+ form = new FormData();
1170
+ form.set('file', new Blob(['a']), 'a.csv');
1171
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/a.csv'), { method: 'POST', body: form })];
1172
+ case 1:
1173
+ _b.sent();
1174
+ child = new FormData();
1175
+ child.set('file', new Blob(['b']), 'b.csv');
1176
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
1177
+ encodeURIComponent('/data/a.csv/child.csv'), { method: 'POST', body: child }))
1178
+ // mkdirp would otherwise turn the file into a folder to hold the child
1179
+ ];
1180
+ case 2:
1181
+ under = _b.sent();
1182
+ // mkdirp would otherwise turn the file into a folder to hold the child
1183
+ expect(under.status).toBe(400);
1184
+ expect(under.body).toBe('An ancestor of the path is a file: /data/a.csv');
1185
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/a.csv'))];
1186
+ case 3:
1187
+ stillAFile = _b.sent();
1188
+ _a = expect;
1189
+ return [4 /*yield*/, stillAFile.text()];
1190
+ case 4:
1191
+ _a.apply(void 0, [_b.sent()]).toBe('a');
1192
+ return [2 /*return*/];
1193
+ }
1194
+ });
1195
+ }); });
1196
+ it('serves a download with the type its extension implies', function () { return __awaiter(void 0, void 0, void 0, function () {
1197
+ var dispatch, form, csv, _a, sas, program;
1198
+ return __generator(this, function (_b) {
1199
+ switch (_b.label) {
1200
+ case 0:
1201
+ dispatch = seeded().dispatch;
1202
+ form = new FormData();
1203
+ form.set('file', new Blob(['id,name\n1,Ann\n']), 'users.csv');
1204
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
1205
+ encodeURIComponent('/data/users.csv'), { method: 'POST', body: form })];
1206
+ case 1:
1207
+ _b.sent();
1208
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' +
1209
+ encodeURIComponent('/data/users.csv'))
1210
+ // an upload is stored as bytes, as multer leaves it on the server; the
1211
+ // extension is what makes it answer text/csv rather than octet-stream
1212
+ ];
1213
+ case 2:
1214
+ csv = _b.sent();
1215
+ // an upload is stored as bytes, as multer leaves it on the server; the
1216
+ // extension is what makes it answer text/csv rather than octet-stream
1217
+ expect(csv.headers.get('content-type')).toBe('text/csv');
1218
+ _a = expect;
1219
+ return [4 /*yield*/, csv.text()];
1220
+ case 3:
1221
+ _a.apply(void 0, [_b.sent()]).toBe('id,name\n1,Ann\n');
1222
+ sas = new FormData();
1223
+ sas.set('file', new Blob(['data _null_; run;']), 'p.sas');
1224
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/p.sas'), { method: 'POST', body: sas })];
1225
+ case 4:
1226
+ _b.sent();
1227
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/p.sas'))];
1228
+ case 5:
1229
+ program = _b.sent();
1230
+ expect(program.headers.get('content-type')).toBe('text/plain');
1231
+ return [2 /*return*/];
1232
+ }
1233
+ });
1234
+ }); });
1235
+ it('refuses an upload larger than the host allows, before reading it', function () { return __awaiter(void 0, void 0, void 0, function () {
1236
+ var dispatch, runtime, form, response;
1237
+ return __generator(this, function (_a) {
1238
+ switch (_a.label) {
1239
+ case 0:
1240
+ dispatch = seeded().dispatch;
1241
+ runtime = loadWorker().runtime;
1242
+ form = new FormData();
1243
+ form.set('file', new Blob([new Uint8Array(runtime.MAX_UPLOAD_BYTES + 1)]), 'big.bin');
1244
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/file?_filePath=' +
1245
+ encodeURIComponent('/data/big.bin'), { method: 'POST', body: form }))];
1246
+ case 1:
1247
+ response = _a.sent();
1248
+ expect(response.status).toBe(413);
1249
+ expect(response.body).toContain('larger than the browser host allows');
1250
+ return [2 /*return*/];
1251
+ }
1252
+ });
1253
+ }); });
1254
+ it('renames a file through the route the API documents', function () { return __awaiter(void 0, void 0, void 0, function () {
1255
+ var dispatch, form, renamed, read, _a;
1256
+ return __generator(this, function (_b) {
1257
+ switch (_b.label) {
1258
+ case 0:
1259
+ dispatch = seeded().dispatch;
1260
+ form = new FormData();
1261
+ form.set('file', new Blob(['r']), 'r.csv');
1262
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/old.csv'), { method: 'POST', body: form })];
1263
+ case 1:
1264
+ _b.sent();
1265
+ return [4 /*yield*/, answer(dispatch('/SASjsApi/drive/rename', {
1266
+ method: 'POST',
1267
+ headers: { 'content-type': 'application/json' },
1268
+ body: JSON.stringify({
1269
+ oldPath: '/data/old.csv',
1270
+ newPath: '/data/new.csv'
1271
+ })
1272
+ }))];
1273
+ case 2:
1274
+ renamed = _b.sent();
1275
+ expect(renamed.body).toEqual({ status: 'success' });
1276
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/new.csv'))];
1277
+ case 3:
1278
+ read = _b.sent();
1279
+ _a = expect;
1280
+ return [4 /*yield*/, read.text()];
1281
+ case 4:
1282
+ _a.apply(void 0, [_b.sent()]).toBe('r');
1283
+ return [2 /*return*/];
1284
+ }
1285
+ });
1286
+ }); });
1287
+ });
802
1288
  });
package/package.json CHANGED
@@ -123,5 +123,5 @@
123
123
  "ws": "8.21.0",
124
124
  "nanoid": "3.3.18"
125
125
  },
126
- "version": "6.10.1"
126
+ "version": "6.11.0"
127
127
  }