@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 +2 -1
- package/build/browser.js +342 -7
- package/build/cjs/corpus/drive.js +119 -0
- package/build/cjs/corpus/runner.js +18 -2
- package/build/cjs/drive/deploy.js +270 -0
- package/build/cjs/drive/service.js +5 -0
- package/build/cjs/host/dispatch.js +8 -0
- package/build/cjs/index.js +10 -1
- package/build/cjs/routes/generated.js +8 -0
- package/build/cjs/routes/overlay.js +21 -3
- package/build/cjs/types.js +11 -1
- package/build/esm/corpus/drive.d.ts +6 -0
- package/build/esm/corpus/drive.d.ts.map +1 -1
- package/build/esm/corpus/drive.js +119 -0
- package/build/esm/corpus/runner.d.ts.map +1 -1
- package/build/esm/corpus/runner.js +18 -2
- package/build/esm/drive/deploy.d.ts +143 -0
- package/build/esm/drive/deploy.d.ts.map +1 -0
- package/build/esm/drive/deploy.js +262 -0
- package/build/esm/drive/service.d.ts +8 -0
- package/build/esm/drive/service.d.ts.map +1 -1
- package/build/esm/drive/service.js +5 -0
- package/build/esm/host/dispatch.d.ts.map +1 -1
- package/build/esm/host/dispatch.js +8 -0
- package/build/esm/index.d.ts +2 -0
- package/build/esm/index.d.ts.map +1 -1
- package/build/esm/index.js +1 -0
- package/build/esm/routes/generated.d.ts.map +1 -1
- package/build/esm/routes/generated.js +8 -0
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +21 -3
- package/build/esm/types.d.ts +19 -1
- package/build/esm/types.d.ts.map +1 -1
- package/build/esm/types.js +11 -1
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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))
|
|
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)(
|
|
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
|
};
|