@sasjs/api 0.5.0 → 0.6.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/README.md CHANGED
@@ -67,7 +67,7 @@ The bundle is deliberately a classic-worker IIFE: a service worker registered by
67
67
  Both hosts answer with the same shapes:
68
68
 
69
69
  - a successful write is `{ status: 'success' }`;
70
- - a refusal is an `ApiError` carrying `status` and `message`, which a host renders as its own transport requires (`res.status(code).send(...)` on the server, a `Response` in the worker);
70
+ - a refusal is an `ApiError` carrying `status`, `message` and, where the route answers an envelope rather than text, the `body` to send instead of the bare message - which a host renders as its own transport requires (`res.status(code).send(...)` on the server, a `Response` in the worker);
71
71
  - an operation this package has no handler for is `501`, naming the operation and the reason it is not implemented here - not a `404` that looks like a typo in the caller;
72
72
  - an operation the API does not have is `404`.
73
73
 
@@ -94,6 +94,7 @@ Three of the sign-in flow's defences are the host's, because the package has no
94
94
  - **Re-resolve the caller on every request.** A host with its own session passes the subject in (`session`, `authorize`), so the service re-reads the account behind it and refuses a deactivated one; a host that caches the subject it resolved at sign-in defeats that check. `logout` is the exception and does not refuse: dropping a credential is not access, and refusing would leave the pair in the store.
95
95
  - **Lock the account out after repeated failures.** `POST /SASLogon/login` answers `401 Username is not found.` for an unknown username and `401 Invalid Password.` for a wrong one, because that is what the server's own form tells a user - which also means the route cannot tell a caller to stop guessing on its own. The defence is the host's limiter, answering `429` with `Retry-After` in seconds; without it the route is a way to enumerate usernames and account state.
96
96
  - **Answer a session-store or identity-provider failure with `500`.** Those are the host's own dependencies, and the overlay marks the statuses as `hostResponses`.
97
+ - **Admit `DEPLOY_PAYLOAD_BYTES` on the deploy route, and only there.** The deploy cap is stated in decoded bytes and its tree travels as JSON in which file contents are base64, so a tree at the cap reaches the parser at up to ~134 MB; a smaller body limit refuses first, with its own shape, and the cap is never consulted. Every other route keeps the smaller limit, because a body is buffered before authentication.
97
98
 
98
99
  ## Development
99
100
 
package/build/browser.js CHANGED
@@ -1,4 +1,4 @@
1
- /* @sasjs/api 0.5.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
1
+ /* @sasjs/api 0.6.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
2
2
  "use strict";
3
3
  var SASjsApi = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -24,6 +24,9 @@ var SASjsApi = (() => {
24
24
  __export(index_exports, {
25
25
  ApiError: () => ApiError,
26
26
  DEFAULT_IDENTITY: () => DEFAULT_IDENTITY,
27
+ DEPLOY_LIMITS: () => DEPLOY_LIMITS,
28
+ DEPLOY_PAYLOAD_BYTES: () => DEPLOY_PAYLOAD_BYTES,
29
+ DEPLOY_SUCCESS: () => DEPLOY_SUCCESS,
27
30
  DRIVE_CORPUS: () => DRIVE_CORPUS,
28
31
  HANDLED_ROUTES: () => HANDLED_ROUTES,
29
32
  IDENTITY_CORPUS: () => IDENTITY_CORPUS,
@@ -34,14 +37,19 @@ var SASjsApi = (() => {
34
37
  ROUTES: () => ROUTES,
35
38
  TAG_CAPABILITIES: () => TAG_CAPABILITIES,
36
39
  basename: () => basename,
40
+ checkDeployTree: () => checkDeployTree,
37
41
  createDriveService: () => createDriveService,
38
42
  createIdentityService: () => createIdentityService,
43
+ decodeBase64: () => decodeBase64,
44
+ deployTree: () => deployTree,
39
45
  dirname: () => dirname,
40
46
  dispatch: () => dispatch,
41
47
  findRoute: () => findRoute,
42
48
  hasBlockedExtension: () => hasBlockedExtension,
43
49
  isApiError: () => isApiError,
50
+ isDeployTree: () => isDeployTree,
44
51
  isRoot: () => isRoot,
52
+ measureDeployTree: () => measureDeployTree,
45
53
  normaliseDrivePath: () => normaliseDrivePath,
46
54
  parseIdentity: () => parseIdentity,
47
55
  routeById: () => routeById,
@@ -51,9 +59,10 @@ var SASjsApi = (() => {
51
59
 
52
60
  // src/types.ts
53
61
  var ApiError = class extends Error {
54
- constructor(status, message) {
62
+ constructor(status, message, body) {
55
63
  super(message);
56
64
  this.status = status;
65
+ this.body = body;
57
66
  /** Told apart by marker rather than `instanceof` - see `isApiError`. */
58
67
  this.apiError = true;
59
68
  this.name = "ApiError";
@@ -91,6 +100,169 @@ var SASjsApi = (() => {
91
100
  return { path };
92
101
  };
93
102
 
103
+ // src/drive/deploy.ts
104
+ var DEPLOY_LIMITS = {
105
+ entries: 1e4,
106
+ totalBytes: 100 * 1024 * 1024,
107
+ fileBytes: 20 * 1024 * 1024
108
+ };
109
+ var DEPLOY_PAYLOAD_BYTES = 140 * 1024 * 1024;
110
+ var DEPLOY_SUCCESS = {
111
+ status: "success",
112
+ message: "Files deployed successfully to @sasjs/server."
113
+ };
114
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
115
+ var base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, "");
116
+ var base64Length = (value) => Math.floor(base64Body(value).length * 3 / 4);
117
+ var decodeBase64 = (value) => {
118
+ const body = base64Body(value);
119
+ const bytes2 = new Uint8Array(Math.floor(body.length * 3 / 4));
120
+ let buffer = 0;
121
+ let bits = 0;
122
+ let at = 0;
123
+ for (const char of body) {
124
+ buffer = buffer << 6 | BASE64_ALPHABET.indexOf(char);
125
+ bits += 6;
126
+ if (bits >= 8) {
127
+ bits -= 8;
128
+ bytes2[at++] = buffer >> bits & 255;
129
+ }
130
+ }
131
+ return bytes2.subarray(0, at);
132
+ };
133
+ var utf8Length = (value) => {
134
+ let bytes2 = 0;
135
+ for (let at = 0; at < value.length; at++) {
136
+ const code = value.charCodeAt(at);
137
+ if (code < 128) bytes2 += 1;
138
+ else if (code < 2048) bytes2 += 2;
139
+ else if (code >= 55296 && code <= 56319 && at + 1 < value.length) {
140
+ bytes2 += 4;
141
+ at++;
142
+ } else bytes2 += 3;
143
+ }
144
+ return bytes2;
145
+ };
146
+ var memberName = (member) => member.type === "service" ? `${member.name}.sas` : member.name;
147
+ var memberBytes = (member) => member.type === "file" ? base64Length(member.code) : utf8Length(member.code);
148
+ var describeSize = (bytes2) => bytes2 < 1024 * 1024 ? `${bytes2} bytes` : `${Math.round(bytes2 / (1024 * 1024) * 10) / 10} MB`;
149
+ var emptyWalk = () => ({
150
+ entries: 0,
151
+ totalBytes: 0,
152
+ largest: { path: "", bytes: 0 }
153
+ });
154
+ var isSafeSegment = (name) => name !== "" && name !== "." && name !== ".." && !/[/\\]/.test(name) && // a Windows drive or a NUL byte is never a segment either
155
+ !/[\0]/.test(name);
156
+ var walk = (members, parentPath, acc) => {
157
+ for (const member of members) {
158
+ const name = memberName(member);
159
+ const memberPath = parentPath ? `${parentPath}/${name}` : name;
160
+ if (!isSafeSegment(name) && !acc.nameRefusal)
161
+ acc.nameRefusal = {
162
+ code: 400,
163
+ status: "failure",
164
+ message: `Invalid member name: '${name}'. Member names must be single path segments (no '/', '\\', '..').`
165
+ };
166
+ acc.entries++;
167
+ if (member.type === "folder") {
168
+ walk(member.members, memberPath, acc);
169
+ continue;
170
+ }
171
+ const bytes2 = memberBytes(member);
172
+ acc.totalBytes += bytes2;
173
+ if (bytes2 > acc.largest.bytes) acc.largest = { path: memberPath, bytes: bytes2 };
174
+ }
175
+ };
176
+ var measureDeployTree = (members) => {
177
+ const acc = emptyWalk();
178
+ walk(members, "", acc);
179
+ return {
180
+ entries: acc.entries,
181
+ totalBytes: acc.totalBytes,
182
+ largest: acc.largest
183
+ };
184
+ };
185
+ var checkDeployTree = (members, limits = DEPLOY_LIMITS) => {
186
+ const measured = emptyWalk();
187
+ walk(members, "", measured);
188
+ if (measured.nameRefusal) return measured.nameRefusal;
189
+ if (measured.entries > limits.entries)
190
+ return {
191
+ code: 413,
192
+ status: "failure",
193
+ message: `The file tree exceeds the deploy limit: ${measured.entries} entries (maximum ${limits.entries}).`
194
+ };
195
+ if (measured.totalBytes > limits.totalBytes)
196
+ return {
197
+ code: 413,
198
+ status: "failure",
199
+ message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeSize(limits.totalBytes)}).`
200
+ };
201
+ if (measured.largest.bytes > limits.fileBytes)
202
+ return {
203
+ code: 413,
204
+ status: "failure",
205
+ message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeSize(limits.fileBytes)} per file).`
206
+ };
207
+ return null;
208
+ };
209
+ var isDeployTree = (value) => {
210
+ if (!value || typeof value !== "object") return false;
211
+ const members = value.members;
212
+ return Array.isArray(members) && members.every(isDeployMember);
213
+ };
214
+ var isDeployMember = (value) => {
215
+ if (!value || typeof value !== "object") return false;
216
+ const member = value;
217
+ if (typeof member.name !== "string") return false;
218
+ if (member.type === "folder")
219
+ return Array.isArray(member.members) && member.members.every(isDeployMember);
220
+ if (member.type === "service" || member.type === "file")
221
+ return typeof member.code === "string";
222
+ return false;
223
+ };
224
+ var deployTree = async (store, appLoc, tree, limits = DEPLOY_LIMITS) => {
225
+ if (!isDeployTree(tree))
226
+ throw new ApiError(400, "Provided not supported data format.", {
227
+ status: "failure",
228
+ message: "Provided not supported data format."
229
+ });
230
+ const root = normaliseDrivePath(appLoc);
231
+ if (root === null)
232
+ throw new ApiError(400, "appLoc cannot be outside drive.", {
233
+ status: "failure",
234
+ message: "appLoc cannot be outside drive."
235
+ });
236
+ const refusal = checkDeployTree(tree.members, limits);
237
+ if (refusal)
238
+ throw new ApiError(refusal.code, refusal.message, {
239
+ status: refusal.status,
240
+ message: refusal.message
241
+ });
242
+ try {
243
+ await writeMembers(store, tree.members, root);
244
+ } catch {
245
+ throw new ApiError(500, "Deployment failed!", {
246
+ status: "failure",
247
+ message: "Deployment failed!"
248
+ });
249
+ }
250
+ };
251
+ var writeMembers = async (store, members, parentPath) => {
252
+ for (const member of members) {
253
+ const path = `${parentPath}/${memberName(member)}`;
254
+ if (member.type === "folder") {
255
+ await store.mkdirp(path);
256
+ await writeMembers(store, member.members, path);
257
+ continue;
258
+ }
259
+ await store.write(
260
+ path,
261
+ member.type === "file" ? decodeBase64(member.code) : member.code
262
+ );
263
+ }
264
+ };
265
+
94
266
  // src/drive/service.ts
95
267
  var filePath = (input, outside) => {
96
268
  const checked = checkFilePath(input);
@@ -226,6 +398,10 @@ var SASjsApi = (() => {
226
398
  const stat = await store.stat(path);
227
399
  if (!stat.directory) throw new ApiError(400, "Not a Folder.");
228
400
  return { status: "success", tree: await treeOf(path) };
401
+ },
402
+ async deploy(appLoc, tree) {
403
+ await deployTree(store, appLoc, tree);
404
+ return { status: "success" };
229
405
  }
230
406
  };
231
407
  };
@@ -816,6 +992,10 @@ var SASjsApi = (() => {
816
992
  status: 400,
817
993
  description: "Invalid Format"
818
994
  },
995
+ {
996
+ status: 413,
997
+ description: "File tree exceeds the deploy limit"
998
+ },
819
999
  {
820
1000
  status: 500,
821
1001
  description: "Execution Error"
@@ -846,6 +1026,10 @@ var SASjsApi = (() => {
846
1026
  status: 400,
847
1027
  description: "Invalid Format"
848
1028
  },
1029
+ {
1030
+ status: 413,
1031
+ description: "File tree exceeds the deploy limit"
1032
+ },
849
1033
  {
850
1034
  status: 500,
851
1035
  description: "Execution Error"
@@ -2078,7 +2262,7 @@ var SASjsApi = (() => {
2078
2262
  var PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
2079
2263
  var HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
2080
2264
  var WEB_BUNDLE = "The host serves its own web bundle; there is no API decision here for this package to answer.";
2081
- var DEPLOY_CAP = "Writing a whole tree in one request can exhaust the host's storage, so the deploy routes land with the size cap that bounds it.";
2265
+ var ARCHIVE = "Expanding the uploaded archive is the host's transport work - multer and a zip library on the server, `DecompressionStream` in a worker - and the host hands this package the tree it produces, which is the same tree `POST /SASjsApi/drive/deploy` takes.";
2082
2266
  var UNPUBLISH = "Unpublishing inverts the deploy route, so the two land together.";
2083
2267
  var success = { status: "success" };
2084
2268
  var user = {
@@ -2181,10 +2365,27 @@ var SASjsApi = (() => {
2181
2365
  implemented: true,
2182
2366
  responses: { 200: { status: "success", tree: {} } }
2183
2367
  },
2184
- "POST /SASjsApi/drive/deploy": { implemented: false, reason: DEPLOY_CAP },
2368
+ // The deploy routes answer an ENVELOPE, unlike the file routes above: their
2369
+ // `400` is `{status:'failure', message}` rather than the message as text, and
2370
+ // `413` is the cap refusing the tree before anything is written.
2371
+ // `"appLoc" is required` and `"fileTree" is required` are the other `400` this
2372
+ // route sends - a missing body property, worded by the table's own
2373
+ // validation - so the body annotated here is the substantial one.
2374
+ "POST /SASjsApi/drive/deploy": {
2375
+ implemented: true,
2376
+ responses: {
2377
+ 200: DEPLOY_SUCCESS,
2378
+ 400: {
2379
+ status: "failure",
2380
+ message: "Provided not supported data format."
2381
+ },
2382
+ 413: "The file tree exceeds the deploy limit.",
2383
+ 500: { status: "failure", message: "Deployment failed!" }
2384
+ }
2385
+ },
2185
2386
  "POST /SASjsApi/drive/deploy/upload": {
2186
2387
  implemented: false,
2187
- reason: DEPLOY_CAP
2388
+ reason: ARCHIVE
2188
2389
  },
2189
2390
  // `404` is a pair - the registry name and the appLoc it was published from -
2190
2391
  // that matches no entry, and the drive's content is deliberately left alone.
@@ -2486,6 +2687,10 @@ var SASjsApi = (() => {
2486
2687
  String(request.body?.newPath || "")
2487
2688
  )
2488
2689
  };
2690
+ case "POST /SASjsApi/drive/deploy": {
2691
+ await drive.deploy(String(body.appLoc ?? ""), body.fileTree);
2692
+ return { status: 200, body: DEPLOY_SUCCESS };
2693
+ }
2489
2694
  case "GET /SASjsApi/drive/filetree":
2490
2695
  return { status: 200, body: await drive.tree(query._folderPath) };
2491
2696
  default: {
@@ -2507,6 +2712,7 @@ var SASjsApi = (() => {
2507
2712
  "DELETE /SASjsApi/drive/folder",
2508
2713
  "POST /SASjsApi/drive/rename",
2509
2714
  "GET /SASjsApi/drive/filetree",
2715
+ "POST /SASjsApi/drive/deploy",
2510
2716
  "POST /SASjsApi/auth/token",
2511
2717
  "POST /SASjsApi/auth/refresh",
2512
2718
  "POST /SASjsApi/auth/logout",
@@ -2873,6 +3079,11 @@ var SASjsApi = (() => {
2873
3079
  // src/corpus/drive.ts
2874
3080
  var success2 = { status: "success" };
2875
3081
  var bytes = (...values) => Uint8Array.from(values);
3082
+ var manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
3083
+ name: `f${at}.txt`,
3084
+ type: "file",
3085
+ code: ""
3086
+ }));
2876
3087
  var DRIVE_CORPUS = [
2877
3088
  {
2878
3089
  id: "create-file",
@@ -3162,6 +3373,119 @@ var SASjsApi = (() => {
3162
3373
  }
3163
3374
  }
3164
3375
  },
3376
+ {
3377
+ id: "deploy-tree",
3378
+ route: "POST /SASjsApi/drive/deploy",
3379
+ description: "writes a whole tree under the appLoc it names",
3380
+ request: {
3381
+ body: {
3382
+ appLoc: "/Public/apps/demo",
3383
+ fileTree: {
3384
+ members: [
3385
+ {
3386
+ name: "services",
3387
+ type: "folder",
3388
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3389
+ },
3390
+ { name: "index.html", type: "file", code: "PGgxPkhlbGxvPC9oMT4=" }
3391
+ ]
3392
+ }
3393
+ }
3394
+ },
3395
+ expect: {
3396
+ status: 200,
3397
+ body: {
3398
+ status: "success",
3399
+ message: "Files deployed successfully to @sasjs/server."
3400
+ }
3401
+ }
3402
+ },
3403
+ {
3404
+ id: "deploy-refused-by-entry-count",
3405
+ route: "POST /SASjsApi/drive/deploy",
3406
+ description: "refuses a tree over the entry cap, naming the figure and the cap",
3407
+ request: {
3408
+ body: {
3409
+ appLoc: "/Public/apps/many",
3410
+ fileTree: { members: manyMembers(10001) }
3411
+ }
3412
+ },
3413
+ expect: {
3414
+ status: 413,
3415
+ body: {
3416
+ status: "failure",
3417
+ message: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3418
+ },
3419
+ error: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3420
+ }
3421
+ },
3422
+ {
3423
+ id: "deploy-refused-by-name",
3424
+ route: "POST /SASjsApi/drive/deploy",
3425
+ description: "refuses a member name that would escape the deployment root",
3426
+ request: {
3427
+ body: {
3428
+ appLoc: "/Public/apps/hostile",
3429
+ fileTree: {
3430
+ members: [{ name: "../escape", type: "service", code: "%put hi;" }]
3431
+ }
3432
+ }
3433
+ },
3434
+ expect: {
3435
+ status: 400,
3436
+ body: {
3437
+ status: "failure",
3438
+ message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3439
+ },
3440
+ error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3441
+ }
3442
+ },
3443
+ {
3444
+ id: "deploy-refused-by-shape",
3445
+ route: "POST /SASjsApi/drive/deploy",
3446
+ description: "refuses a fileTree that is not a tree, in the routes own envelope",
3447
+ request: { body: { appLoc: "/Public/apps/bad", fileTree: { members: 1 } } },
3448
+ expect: {
3449
+ status: 400,
3450
+ body: {
3451
+ status: "failure",
3452
+ message: "Provided not supported data format."
3453
+ },
3454
+ error: "Provided not supported data format."
3455
+ }
3456
+ },
3457
+ {
3458
+ id: "deploy-refused-by-store",
3459
+ route: "POST /SASjsApi/drive/deploy",
3460
+ description: "answers the routes 500 when the drive refuses the write",
3461
+ unwritable: true,
3462
+ request: {
3463
+ body: {
3464
+ appLoc: "/Public/apps/demo",
3465
+ fileTree: {
3466
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3467
+ }
3468
+ }
3469
+ },
3470
+ expect: {
3471
+ status: 500,
3472
+ body: { status: "failure", message: "Deployment failed!" },
3473
+ error: "Deployment failed!"
3474
+ }
3475
+ },
3476
+ {
3477
+ id: "deploy-refused-outside-drive",
3478
+ route: "POST /SASjsApi/drive/deploy",
3479
+ description: "refuses an appLoc that resolves outside the drive",
3480
+ request: {
3481
+ body: { appLoc: "/../outside", fileTree: { members: [] } }
3482
+ },
3483
+ expect: {
3484
+ status: 400,
3485
+ body: { status: "failure", message: "appLoc cannot be outside drive." },
3486
+ error: "appLoc cannot be outside drive."
3487
+ }
3488
+ },
3165
3489
  {
3166
3490
  id: "bytes-round-trip",
3167
3491
  route: "GET /SASjsApi/drive/file",
@@ -3439,11 +3763,21 @@ var SASjsApi = (() => {
3439
3763
 
3440
3764
  // src/corpus/runner.ts
3441
3765
  var servicesFor = (testCase) => ({
3442
- drive: createDriveService(new MemoryDriveStore(testCase.seed || {})),
3766
+ drive: createDriveService(
3767
+ testCase.unwritable ? new RefusingDriveStore(testCase.seed || {}) : new MemoryDriveStore(testCase.seed || {})
3768
+ ),
3443
3769
  identity: createIdentityService(
3444
3770
  new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY)
3445
3771
  )
3446
3772
  });
3773
+ var RefusingDriveStore = class extends MemoryDriveStore {
3774
+ async write() {
3775
+ throw new Error("the store refused the write");
3776
+ }
3777
+ async mkdirp() {
3778
+ throw new Error("the store refused the write");
3779
+ }
3780
+ };
3447
3781
  var runCase = async (testCase, services = servicesFor(testCase)) => {
3448
3782
  try {
3449
3783
  const response = await dispatch(services, testCase.route, testCase.request);
@@ -3453,7 +3787,8 @@ var SASjsApi = (() => {
3453
3787
  contents: response.contents
3454
3788
  };
3455
3789
  } catch (error) {
3456
- if (isApiError(error)) return { status: error.status, error: error.message };
3790
+ if (isApiError(error))
3791
+ return { status: error.status, body: error.body, error: error.message };
3457
3792
  throw error;
3458
3793
  }
3459
3794
  };
@@ -8,6 +8,12 @@ const success = { status: 'success' };
8
8
  * host that does not have one. An explicit byte array is both.
9
9
  */
10
10
  const bytes = (...values) => Uint8Array.from(values);
11
+ /** A flat tree of `count` empty file members, for the cap's entry dimension. */
12
+ const manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
13
+ name: `f${at}.txt`,
14
+ type: 'file',
15
+ code: ''
16
+ }));
11
17
  exports.DRIVE_CORPUS = [
12
18
  {
13
19
  id: 'create-file',
@@ -297,6 +303,119 @@ exports.DRIVE_CORPUS = [
297
303
  }
298
304
  }
299
305
  },
306
+ {
307
+ id: 'deploy-tree',
308
+ route: 'POST /SASjsApi/drive/deploy',
309
+ description: 'writes a whole tree under the appLoc it names',
310
+ request: {
311
+ body: {
312
+ appLoc: '/Public/apps/demo',
313
+ fileTree: {
314
+ members: [
315
+ {
316
+ name: 'services',
317
+ type: 'folder',
318
+ members: [{ name: 'run', type: 'service', code: '%put hi;' }]
319
+ },
320
+ { name: 'index.html', type: 'file', code: 'PGgxPkhlbGxvPC9oMT4=' }
321
+ ]
322
+ }
323
+ }
324
+ },
325
+ expect: {
326
+ status: 200,
327
+ body: {
328
+ status: 'success',
329
+ message: 'Files deployed successfully to @sasjs/server.'
330
+ }
331
+ }
332
+ },
333
+ {
334
+ id: 'deploy-refused-by-entry-count',
335
+ route: 'POST /SASjsApi/drive/deploy',
336
+ description: 'refuses a tree over the entry cap, naming the figure and the cap',
337
+ request: {
338
+ body: {
339
+ appLoc: '/Public/apps/many',
340
+ fileTree: { members: manyMembers(10001) }
341
+ }
342
+ },
343
+ expect: {
344
+ status: 413,
345
+ body: {
346
+ status: 'failure',
347
+ message: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
348
+ },
349
+ error: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
350
+ }
351
+ },
352
+ {
353
+ id: 'deploy-refused-by-name',
354
+ route: 'POST /SASjsApi/drive/deploy',
355
+ description: 'refuses a member name that would escape the deployment root',
356
+ request: {
357
+ body: {
358
+ appLoc: '/Public/apps/hostile',
359
+ fileTree: {
360
+ members: [{ name: '../escape', type: 'service', code: '%put hi;' }]
361
+ }
362
+ }
363
+ },
364
+ expect: {
365
+ status: 400,
366
+ body: {
367
+ status: 'failure',
368
+ message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
369
+ },
370
+ error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
371
+ }
372
+ },
373
+ {
374
+ id: 'deploy-refused-by-shape',
375
+ route: 'POST /SASjsApi/drive/deploy',
376
+ description: 'refuses a fileTree that is not a tree, in the routes own envelope',
377
+ request: { body: { appLoc: '/Public/apps/bad', fileTree: { members: 1 } } },
378
+ expect: {
379
+ status: 400,
380
+ body: {
381
+ status: 'failure',
382
+ message: 'Provided not supported data format.'
383
+ },
384
+ error: 'Provided not supported data format.'
385
+ }
386
+ },
387
+ {
388
+ id: 'deploy-refused-by-store',
389
+ route: 'POST /SASjsApi/drive/deploy',
390
+ description: 'answers the routes 500 when the drive refuses the write',
391
+ unwritable: true,
392
+ request: {
393
+ body: {
394
+ appLoc: '/Public/apps/demo',
395
+ fileTree: {
396
+ members: [{ name: 'run', type: 'service', code: '%put hi;' }]
397
+ }
398
+ }
399
+ },
400
+ expect: {
401
+ status: 500,
402
+ body: { status: 'failure', message: 'Deployment failed!' },
403
+ error: 'Deployment failed!'
404
+ }
405
+ },
406
+ {
407
+ id: 'deploy-refused-outside-drive',
408
+ route: 'POST /SASjsApi/drive/deploy',
409
+ description: 'refuses an appLoc that resolves outside the drive',
410
+ request: {
411
+ body: { appLoc: '/../outside', fileTree: { members: [] } }
412
+ },
413
+ expect: {
414
+ status: 400,
415
+ body: { status: 'failure', message: 'appLoc cannot be outside drive.' },
416
+ error: 'appLoc cannot be outside drive.'
417
+ }
418
+ },
300
419
  {
301
420
  id: 'bytes-round-trip',
302
421
  route: 'GET /SASjsApi/drive/file',
@@ -15,10 +15,26 @@ const types_js_1 = require("../types.js");
15
15
  * mid-flow can declare the tokens and codes it begins with.
16
16
  */
17
17
  const servicesFor = (testCase) => ({
18
- drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
18
+ drive: (0, service_js_1.createDriveService)(testCase.unwritable
19
+ ? new RefusingDriveStore(testCase.seed || {})
20
+ : new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
19
21
  identity: (0, service_js_2.createIdentityService)(new memory_store_js_2.MemoryIdentityStore(testCase.identity || fixture_js_1.DEFAULT_IDENTITY))
20
22
  });
21
23
  exports.servicesFor = servicesFor;
24
+ /**
25
+ * A drive that reads but refuses every write.
26
+ *
27
+ * Only the corpus uses it: `POST /drive/deploy` answers `500` when storing
28
+ * fails, and a case for that answer needs a store that fails.
29
+ */
30
+ class RefusingDriveStore extends memory_store_js_1.MemoryDriveStore {
31
+ async write() {
32
+ throw new Error('the store refused the write');
33
+ }
34
+ async mkdirp() {
35
+ throw new Error('the store refused the write');
36
+ }
37
+ }
22
38
  /**
23
39
  * Runs a corpus case against the drive the case seeds itself, or against
24
40
  * services a caller supplies - that is how a host (or a failing store) is put
@@ -37,7 +53,7 @@ const runCase = async (testCase, services = (0, exports.servicesFor)(testCase))
37
53
  // only a refusal the API decided on becomes a status; anything else is a
38
54
  // broken host, and reporting it as a 500 would hide it from the suite
39
55
  if ((0, types_js_1.isApiError)(error))
40
- return { status: error.status, error: error.message };
56
+ return { status: error.status, body: error.body, error: error.message };
41
57
  throw error;
42
58
  }
43
59
  };