@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.
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +225 -1
- package/build/commands/build/internal/mockSite/assets/sw.js +195 -14
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +245 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +490 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a
|
|
19
|
-
*
|
|
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
|
-
|
|
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/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
517
|
+
return [4 /*yield*/, response.text()];
|
|
518
518
|
case 2:
|
|
519
|
-
_a.apply(void 0, [
|
|
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.
|
|
570
|
+
return [4 /*yield*/, response.text()];
|
|
571
571
|
case 2:
|
|
572
|
-
_a.apply(void 0, [
|
|
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