@sasjs/api 0.5.0 → 0.6.1

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.1 - 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,182 @@ 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 MAX_DEPTH = 32;
115
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
116
+ var base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, "");
117
+ var base64Length = (value) => Math.floor(base64Body(value).length * 3 / 4);
118
+ var decodeBase64 = (value) => {
119
+ const body = base64Body(value);
120
+ const bytes2 = new Uint8Array(Math.floor(body.length * 3 / 4));
121
+ let buffer = 0;
122
+ let bits = 0;
123
+ let at = 0;
124
+ for (const char of body) {
125
+ buffer = buffer << 6 | BASE64_ALPHABET.indexOf(char);
126
+ bits += 6;
127
+ if (bits >= 8) {
128
+ bits -= 8;
129
+ bytes2[at++] = buffer >> bits & 255;
130
+ }
131
+ }
132
+ return bytes2.subarray(0, at);
133
+ };
134
+ var utf8Length = (value) => {
135
+ let bytes2 = 0;
136
+ for (let at = 0; at < value.length; at++) {
137
+ const code = value.charCodeAt(at);
138
+ if (code < 128) bytes2 += 1;
139
+ else if (code < 2048) bytes2 += 2;
140
+ else if (code >= 55296 && code <= 56319 && at + 1 < value.length) {
141
+ bytes2 += 4;
142
+ at++;
143
+ } else bytes2 += 3;
144
+ }
145
+ return bytes2;
146
+ };
147
+ var memberName = (member) => member.type === "service" ? `${member.name}.sas` : member.name;
148
+ var memberBytes = (member) => member.type === "file" ? base64Length(member.code) : utf8Length(member.code);
149
+ var MEGABYTE = 1024 * 1024;
150
+ var describeSize = (bytes2) => bytes2 < MEGABYTE ? `${bytes2} bytes` : `${Math.ceil(bytes2 / MEGABYTE * 10) / 10} MB`;
151
+ var describeLimit = (bytes2) => bytes2 < MEGABYTE ? `${bytes2} bytes` : `${Math.round(bytes2 / MEGABYTE * 10) / 10} MB`;
152
+ var emptyWalk = () => ({
153
+ entries: 0,
154
+ totalBytes: 0,
155
+ largest: { path: "", bytes: 0 }
156
+ });
157
+ var isSafeSegment = (name) => name !== "" && name !== "." && name !== ".." && !/[/\\]/.test(name) && // a Windows drive or a NUL byte is never a segment either
158
+ !/[\0]/.test(name);
159
+ var walk = (members, parentPath, acc, depth = 1) => {
160
+ for (const member of members) {
161
+ const name = memberName(member);
162
+ const memberPath = parentPath ? `${parentPath}/${name}` : name;
163
+ if (!isSafeSegment(name) && !acc.nameRefusal)
164
+ acc.nameRefusal = {
165
+ code: 400,
166
+ status: "failure",
167
+ message: `Invalid member name: '${name}'. Member names must be single path segments (no '/', '\\', '..').`
168
+ };
169
+ acc.entries++;
170
+ if (member.type === "folder") {
171
+ if (depth + 1 > MAX_DEPTH) {
172
+ if (!acc.tooDeep) acc.tooDeep = depth + 1;
173
+ continue;
174
+ }
175
+ walk(member.members, memberPath, acc, depth + 1);
176
+ continue;
177
+ }
178
+ const bytes2 = memberBytes(member);
179
+ acc.totalBytes += bytes2;
180
+ if (bytes2 > acc.largest.bytes) acc.largest = { path: memberPath, bytes: bytes2 };
181
+ }
182
+ };
183
+ var measureDeployTree = (members) => {
184
+ const acc = emptyWalk();
185
+ walk(members, "", acc);
186
+ return {
187
+ entries: acc.entries,
188
+ totalBytes: acc.totalBytes,
189
+ largest: acc.largest
190
+ };
191
+ };
192
+ var checkDeployTree = (members, limits = DEPLOY_LIMITS) => {
193
+ const measured = emptyWalk();
194
+ walk(members, "", measured);
195
+ if (measured.nameRefusal) return measured.nameRefusal;
196
+ if (measured.tooDeep)
197
+ return {
198
+ code: 413,
199
+ status: "failure",
200
+ message: `The file tree is nested too deeply: ${measured.tooDeep} levels (maximum ${MAX_DEPTH}).`
201
+ };
202
+ if (measured.entries > limits.entries)
203
+ return {
204
+ code: 413,
205
+ status: "failure",
206
+ message: `The file tree exceeds the deploy limit: ${measured.entries} entries (maximum ${limits.entries}).`
207
+ };
208
+ if (measured.totalBytes > limits.totalBytes)
209
+ return {
210
+ code: 413,
211
+ status: "failure",
212
+ message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeLimit(limits.totalBytes)}).`
213
+ };
214
+ if (measured.largest.bytes > limits.fileBytes)
215
+ return {
216
+ code: 413,
217
+ status: "failure",
218
+ message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeLimit(limits.fileBytes)} per file).`
219
+ };
220
+ return null;
221
+ };
222
+ var isDeployTree = (value) => {
223
+ if (!value || typeof value !== "object") return false;
224
+ const members = value.members;
225
+ return Array.isArray(members) && members.every(isDeployMember);
226
+ };
227
+ var isDeployMember = (value) => {
228
+ if (!value || typeof value !== "object") return false;
229
+ const member = value;
230
+ if (typeof member.name !== "string") return false;
231
+ if (member.type === "folder")
232
+ return Array.isArray(member.members) && member.members.every(isDeployMember);
233
+ if (member.type === "service" || member.type === "file")
234
+ return typeof member.code === "string";
235
+ return false;
236
+ };
237
+ var deployTree = async (store, appLoc, tree, limits = DEPLOY_LIMITS) => {
238
+ if (!isDeployTree(tree))
239
+ throw new ApiError(400, "Provided not supported data format.", {
240
+ status: "failure",
241
+ message: "Provided not supported data format."
242
+ });
243
+ const root = normaliseDrivePath(appLoc);
244
+ if (root === null)
245
+ throw new ApiError(400, "appLoc cannot be outside drive.", {
246
+ status: "failure",
247
+ message: "appLoc cannot be outside drive."
248
+ });
249
+ const refusal = checkDeployTree(tree.members, limits);
250
+ if (refusal)
251
+ throw new ApiError(refusal.code, refusal.message, {
252
+ status: refusal.status,
253
+ message: refusal.message
254
+ });
255
+ try {
256
+ await writeMembers(store, tree.members, root);
257
+ } catch {
258
+ throw new ApiError(500, "Deployment failed!", {
259
+ status: "failure",
260
+ message: "Deployment failed!"
261
+ });
262
+ }
263
+ };
264
+ var writeMembers = async (store, members, parentPath) => {
265
+ for (const member of members) {
266
+ const path = `${parentPath}/${memberName(member)}`;
267
+ if (member.type === "folder") {
268
+ await store.mkdirp(path);
269
+ await writeMembers(store, member.members, path);
270
+ continue;
271
+ }
272
+ await store.write(
273
+ path,
274
+ member.type === "file" ? decodeBase64(member.code) : member.code
275
+ );
276
+ }
277
+ };
278
+
94
279
  // src/drive/service.ts
95
280
  var filePath = (input, outside) => {
96
281
  const checked = checkFilePath(input);
@@ -226,6 +411,10 @@ var SASjsApi = (() => {
226
411
  const stat = await store.stat(path);
227
412
  if (!stat.directory) throw new ApiError(400, "Not a Folder.");
228
413
  return { status: "success", tree: await treeOf(path) };
414
+ },
415
+ async deploy(appLoc, tree) {
416
+ await deployTree(store, appLoc, tree);
417
+ return { status: "success" };
229
418
  }
230
419
  };
231
420
  };
@@ -816,6 +1005,10 @@ var SASjsApi = (() => {
816
1005
  status: 400,
817
1006
  description: "Invalid Format"
818
1007
  },
1008
+ {
1009
+ status: 413,
1010
+ description: "File tree exceeds the deploy limit"
1011
+ },
819
1012
  {
820
1013
  status: 500,
821
1014
  description: "Execution Error"
@@ -846,6 +1039,10 @@ var SASjsApi = (() => {
846
1039
  status: 400,
847
1040
  description: "Invalid Format"
848
1041
  },
1042
+ {
1043
+ status: 413,
1044
+ description: "File tree exceeds the deploy limit"
1045
+ },
849
1046
  {
850
1047
  status: 500,
851
1048
  description: "Execution Error"
@@ -2078,7 +2275,7 @@ var SASjsApi = (() => {
2078
2275
  var PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
2079
2276
  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
2277
  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.";
2278
+ 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
2279
  var UNPUBLISH = "Unpublishing inverts the deploy route, so the two land together.";
2083
2280
  var success = { status: "success" };
2084
2281
  var user = {
@@ -2181,10 +2378,27 @@ var SASjsApi = (() => {
2181
2378
  implemented: true,
2182
2379
  responses: { 200: { status: "success", tree: {} } }
2183
2380
  },
2184
- "POST /SASjsApi/drive/deploy": { implemented: false, reason: DEPLOY_CAP },
2381
+ // The deploy routes answer an ENVELOPE, unlike the file routes above: their
2382
+ // `400` is `{status:'failure', message}` rather than the message as text, and
2383
+ // `413` is the cap refusing the tree before anything is written.
2384
+ // `"appLoc" is required` and `"fileTree" is required` are the other `400` this
2385
+ // route sends - a missing body property, worded by the table's own
2386
+ // validation - so the body annotated here is the substantial one.
2387
+ "POST /SASjsApi/drive/deploy": {
2388
+ implemented: true,
2389
+ responses: {
2390
+ 200: DEPLOY_SUCCESS,
2391
+ 400: {
2392
+ status: "failure",
2393
+ message: "Provided not supported data format."
2394
+ },
2395
+ 413: "The file tree exceeds the deploy limit.",
2396
+ 500: { status: "failure", message: "Deployment failed!" }
2397
+ }
2398
+ },
2185
2399
  "POST /SASjsApi/drive/deploy/upload": {
2186
2400
  implemented: false,
2187
- reason: DEPLOY_CAP
2401
+ reason: ARCHIVE
2188
2402
  },
2189
2403
  // `404` is a pair - the registry name and the appLoc it was published from -
2190
2404
  // that matches no entry, and the drive's content is deliberately left alone.
@@ -2486,6 +2700,10 @@ var SASjsApi = (() => {
2486
2700
  String(request.body?.newPath || "")
2487
2701
  )
2488
2702
  };
2703
+ case "POST /SASjsApi/drive/deploy": {
2704
+ await drive.deploy(String(body.appLoc ?? ""), body.fileTree);
2705
+ return { status: 200, body: DEPLOY_SUCCESS };
2706
+ }
2489
2707
  case "GET /SASjsApi/drive/filetree":
2490
2708
  return { status: 200, body: await drive.tree(query._folderPath) };
2491
2709
  default: {
@@ -2507,6 +2725,7 @@ var SASjsApi = (() => {
2507
2725
  "DELETE /SASjsApi/drive/folder",
2508
2726
  "POST /SASjsApi/drive/rename",
2509
2727
  "GET /SASjsApi/drive/filetree",
2728
+ "POST /SASjsApi/drive/deploy",
2510
2729
  "POST /SASjsApi/auth/token",
2511
2730
  "POST /SASjsApi/auth/refresh",
2512
2731
  "POST /SASjsApi/auth/logout",
@@ -2873,6 +3092,11 @@ var SASjsApi = (() => {
2873
3092
  // src/corpus/drive.ts
2874
3093
  var success2 = { status: "success" };
2875
3094
  var bytes = (...values) => Uint8Array.from(values);
3095
+ var manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
3096
+ name: `f${at}.txt`,
3097
+ type: "file",
3098
+ code: ""
3099
+ }));
2876
3100
  var DRIVE_CORPUS = [
2877
3101
  {
2878
3102
  id: "create-file",
@@ -3162,6 +3386,119 @@ var SASjsApi = (() => {
3162
3386
  }
3163
3387
  }
3164
3388
  },
3389
+ {
3390
+ id: "deploy-tree",
3391
+ route: "POST /SASjsApi/drive/deploy",
3392
+ description: "writes a whole tree under the appLoc it names",
3393
+ request: {
3394
+ body: {
3395
+ appLoc: "/Public/apps/demo",
3396
+ fileTree: {
3397
+ members: [
3398
+ {
3399
+ name: "services",
3400
+ type: "folder",
3401
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3402
+ },
3403
+ { name: "index.html", type: "file", code: "PGgxPkhlbGxvPC9oMT4=" }
3404
+ ]
3405
+ }
3406
+ }
3407
+ },
3408
+ expect: {
3409
+ status: 200,
3410
+ body: {
3411
+ status: "success",
3412
+ message: "Files deployed successfully to @sasjs/server."
3413
+ }
3414
+ }
3415
+ },
3416
+ {
3417
+ id: "deploy-refused-by-entry-count",
3418
+ route: "POST /SASjsApi/drive/deploy",
3419
+ description: "refuses a tree over the entry cap, naming the figure and the cap",
3420
+ request: {
3421
+ body: {
3422
+ appLoc: "/Public/apps/many",
3423
+ fileTree: { members: manyMembers(10001) }
3424
+ }
3425
+ },
3426
+ expect: {
3427
+ status: 413,
3428
+ body: {
3429
+ status: "failure",
3430
+ message: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3431
+ },
3432
+ error: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3433
+ }
3434
+ },
3435
+ {
3436
+ id: "deploy-refused-by-name",
3437
+ route: "POST /SASjsApi/drive/deploy",
3438
+ description: "refuses a member name that would escape the deployment root",
3439
+ request: {
3440
+ body: {
3441
+ appLoc: "/Public/apps/hostile",
3442
+ fileTree: {
3443
+ members: [{ name: "../escape", type: "service", code: "%put hi;" }]
3444
+ }
3445
+ }
3446
+ },
3447
+ expect: {
3448
+ status: 400,
3449
+ body: {
3450
+ status: "failure",
3451
+ message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3452
+ },
3453
+ error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3454
+ }
3455
+ },
3456
+ {
3457
+ id: "deploy-refused-by-shape",
3458
+ route: "POST /SASjsApi/drive/deploy",
3459
+ description: "refuses a fileTree that is not a tree, in the routes own envelope",
3460
+ request: { body: { appLoc: "/Public/apps/bad", fileTree: { members: 1 } } },
3461
+ expect: {
3462
+ status: 400,
3463
+ body: {
3464
+ status: "failure",
3465
+ message: "Provided not supported data format."
3466
+ },
3467
+ error: "Provided not supported data format."
3468
+ }
3469
+ },
3470
+ {
3471
+ id: "deploy-refused-by-store",
3472
+ route: "POST /SASjsApi/drive/deploy",
3473
+ description: "answers the routes 500 when the drive refuses the write",
3474
+ unwritable: true,
3475
+ request: {
3476
+ body: {
3477
+ appLoc: "/Public/apps/demo",
3478
+ fileTree: {
3479
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3480
+ }
3481
+ }
3482
+ },
3483
+ expect: {
3484
+ status: 500,
3485
+ body: { status: "failure", message: "Deployment failed!" },
3486
+ error: "Deployment failed!"
3487
+ }
3488
+ },
3489
+ {
3490
+ id: "deploy-refused-outside-drive",
3491
+ route: "POST /SASjsApi/drive/deploy",
3492
+ description: "refuses an appLoc that resolves outside the drive",
3493
+ request: {
3494
+ body: { appLoc: "/../outside", fileTree: { members: [] } }
3495
+ },
3496
+ expect: {
3497
+ status: 400,
3498
+ body: { status: "failure", message: "appLoc cannot be outside drive." },
3499
+ error: "appLoc cannot be outside drive."
3500
+ }
3501
+ },
3165
3502
  {
3166
3503
  id: "bytes-round-trip",
3167
3504
  route: "GET /SASjsApi/drive/file",
@@ -3439,11 +3776,21 @@ var SASjsApi = (() => {
3439
3776
 
3440
3777
  // src/corpus/runner.ts
3441
3778
  var servicesFor = (testCase) => ({
3442
- drive: createDriveService(new MemoryDriveStore(testCase.seed || {})),
3779
+ drive: createDriveService(
3780
+ testCase.unwritable ? new RefusingDriveStore(testCase.seed || {}) : new MemoryDriveStore(testCase.seed || {})
3781
+ ),
3443
3782
  identity: createIdentityService(
3444
3783
  new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY)
3445
3784
  )
3446
3785
  });
3786
+ var RefusingDriveStore = class extends MemoryDriveStore {
3787
+ async write() {
3788
+ throw new Error("the store refused the write");
3789
+ }
3790
+ async mkdirp() {
3791
+ throw new Error("the store refused the write");
3792
+ }
3793
+ };
3447
3794
  var runCase = async (testCase, services = servicesFor(testCase)) => {
3448
3795
  try {
3449
3796
  const response = await dispatch(services, testCase.route, testCase.request);
@@ -3453,7 +3800,8 @@ var SASjsApi = (() => {
3453
3800
  contents: response.contents
3454
3801
  };
3455
3802
  } catch (error) {
3456
- if (isApiError(error)) return { status: error.status, error: error.message };
3803
+ if (isApiError(error))
3804
+ return { status: error.status, body: error.body, error: error.message };
3457
3805
  throw error;
3458
3806
  }
3459
3807
  };
@@ -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
  };