@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 +2 -1
- package/build/browser.js +355 -7
- package/build/cjs/corpus/drive.js +119 -0
- package/build/cjs/corpus/runner.js +18 -2
- package/build/cjs/drive/deploy.js +309 -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 +153 -0
- package/build/esm/drive/deploy.d.ts.map +1 -0
- package/build/esm/drive/deploy.js +301 -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.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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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))
|
|
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)(
|
|
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
|
};
|