@sasjs/api 0.4.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 +12 -13
- package/build/browser.js +515 -54
- 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 +15 -2
- package/build/cjs/index.js +10 -1
- package/build/cjs/routes/generated.js +8 -0
- package/build/cjs/routes/index.js +8 -2
- package/build/cjs/routes/overlay.js +181 -40
- 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 +15 -2
- 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/index.d.ts.map +1 -1
- package/build/esm/routes/index.js +8 -2
- package/build/esm/routes/overlay.d.ts +26 -3
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +181 -40
- package/build/esm/types.d.ts +26 -1
- package/build/esm/types.d.ts.map +1 -1
- package/build/esm/types.js +11 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.METHODS = exports.OVERLAY = exports.TAG_CAPABILITIES = void 0;
|
|
4
|
+
const deploy_js_1 = require("../drive/deploy.js");
|
|
4
5
|
/** The default capability for each tag, overridden per route where it differs. */
|
|
5
6
|
exports.TAG_CAPABILITIES = {
|
|
6
7
|
Drive: ['storage'],
|
|
@@ -17,6 +18,23 @@ exports.TAG_CAPABILITIES = {
|
|
|
17
18
|
Client: ['identity'],
|
|
18
19
|
Web: ['identity']
|
|
19
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* Why a block of operations is not implemented here.
|
|
23
|
+
*
|
|
24
|
+
* One per dependency rather than one per route: the reason states what the
|
|
25
|
+
* operation waits on, and thirty copies of the same sentence would say the same
|
|
26
|
+
* thing thirty times.
|
|
27
|
+
*/
|
|
28
|
+
const IDENTITY_RECORDS = 'The identity service answers the sign-in flow. User, group, permission rule, client and provider records need read and write methods on the same store, which this package does not have yet.';
|
|
29
|
+
const EDITOR = 'Linting and formatting come from @sasjs/lint, which this package does not depend on. They land with the code routes so an editor has one wiring.';
|
|
30
|
+
const RUNTIME = 'Starting a program needs a runtime port, which this package does not have: a host decides which languages it holds, and answers this itself until then.';
|
|
31
|
+
const SESSION_RECORD = "A triggered session's record, log, output and state belong to the runtime that ran the program, and this package has no runtime port.";
|
|
32
|
+
const HOST_CONFIG = "The body is the host's own configuration - ports, run times and providers - which a mock states for itself and this package cannot know.";
|
|
33
|
+
const PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
|
|
34
|
+
const HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
|
|
35
|
+
const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
|
|
36
|
+
const 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.";
|
|
37
|
+
const UNPUBLISH = 'Unpublishing inverts the deploy route, so the two land together.';
|
|
20
38
|
const success = { status: 'success' };
|
|
21
39
|
const user = {
|
|
22
40
|
uid: '',
|
|
@@ -26,11 +44,61 @@ const user = {
|
|
|
26
44
|
needsToUpdatePassword: false
|
|
27
45
|
};
|
|
28
46
|
exports.OVERLAY = {
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
|
|
47
|
+
// Web - the sign-in pages the identity service answers, and the bundle itself
|
|
48
|
+
'GET /': { implemented: false, reason: WEB_BUNDLE },
|
|
49
|
+
'POST /SASLogon/login': {
|
|
50
|
+
implemented: true,
|
|
51
|
+
// The lockout and the provider failure are answered before the package is
|
|
52
|
+
// consulted (routes/web/web.ts in sasjs/server).
|
|
53
|
+
hostResponses: [429, 500],
|
|
54
|
+
responses: { 200: { loggedIn: true, user } }
|
|
55
|
+
},
|
|
56
|
+
'POST /SASLogon/authorize': {
|
|
57
|
+
implemented: true,
|
|
58
|
+
responses: { 200: { code: '' } }
|
|
59
|
+
},
|
|
60
|
+
'GET /SASLogon/logout': {
|
|
61
|
+
implemented: true,
|
|
62
|
+
hostResponses: [500],
|
|
63
|
+
responses: { 200: 'OK!' }
|
|
64
|
+
},
|
|
65
|
+
// Admin - the host's own process and files
|
|
66
|
+
'GET /SASjsApi/admin/diagnostics': { implemented: false, reason: HOST_OWNED },
|
|
67
|
+
'GET /SASjsApi/admin/logs': { implemented: false, reason: HOST_OWNED },
|
|
68
|
+
'GET /SASjsApi/admin/logs/{fileName}': {
|
|
69
|
+
implemented: false,
|
|
70
|
+
reason: HOST_OWNED
|
|
71
|
+
},
|
|
72
|
+
// Auth - the token flow, plus a password change the identity store cannot write
|
|
73
|
+
'POST /SASjsApi/auth/token': {
|
|
74
|
+
implemented: true,
|
|
75
|
+
responses: { 200: { accessToken: '', refreshToken: '' } }
|
|
76
|
+
},
|
|
77
|
+
'POST /SASjsApi/auth/refresh': {
|
|
78
|
+
implemented: true,
|
|
79
|
+
responses: { 200: { accessToken: '', refreshToken: '' } }
|
|
80
|
+
},
|
|
81
|
+
'POST /SASjsApi/auth/logout': { implemented: true },
|
|
82
|
+
'PATCH /SASjsApi/auth/updatePassword': {
|
|
83
|
+
implemented: false,
|
|
84
|
+
reason: IDENTITY_RECORDS
|
|
85
|
+
},
|
|
86
|
+
// Auth Config - the providers a host was started with, and an LDAP import
|
|
87
|
+
'GET /SASjsApi/authConfig': { implemented: false, reason: HOST_CONFIG },
|
|
88
|
+
'POST /SASjsApi/authConfig/synchroniseWithLDAP': {
|
|
89
|
+
implemented: false,
|
|
90
|
+
reason: IDENTITY_RECORDS
|
|
91
|
+
},
|
|
92
|
+
// Client - the OAuth clients the identity store already holds for sign-in
|
|
93
|
+
'GET /SASjsApi/client': { implemented: false, reason: IDENTITY_RECORDS },
|
|
94
|
+
'POST /SASjsApi/client': { implemented: false, reason: IDENTITY_RECORDS },
|
|
95
|
+
// Code - the editor's three calls, and the runtime one
|
|
96
|
+
'POST /SASjsApi/code/execute': { implemented: false, reason: RUNTIME },
|
|
97
|
+
'POST /SASjsApi/code/trigger': { implemented: false, reason: RUNTIME },
|
|
98
|
+
'POST /SASjsApi/code/lint': { implemented: false, reason: EDITOR },
|
|
99
|
+
'POST /SASjsApi/code/format': { implemented: false, reason: EDITOR },
|
|
100
|
+
// Drive - the file and folder rules, and the three deploy routes
|
|
101
|
+
// The body is the file itself, so there is nothing to annotate
|
|
34
102
|
'GET /SASjsApi/drive/file': { implemented: true },
|
|
35
103
|
'POST /SASjsApi/drive/file': {
|
|
36
104
|
implemented: true,
|
|
@@ -68,54 +136,127 @@ exports.OVERLAY = {
|
|
|
68
136
|
implemented: true,
|
|
69
137
|
responses: { 200: { status: 'success', tree: {} } }
|
|
70
138
|
},
|
|
71
|
-
// The
|
|
72
|
-
//
|
|
73
|
-
//
|
|
139
|
+
// The deploy routes answer an ENVELOPE, unlike the file routes above: their
|
|
140
|
+
// `400` is `{status:'failure', message}` rather than the message as text, and
|
|
141
|
+
// `413` is the cap refusing the tree before anything is written.
|
|
142
|
+
// `"appLoc" is required` and `"fileTree" is required` are the other `400` this
|
|
143
|
+
// route sends - a missing body property, worded by the table's own
|
|
144
|
+
// validation - so the body annotated here is the substantial one.
|
|
74
145
|
'POST /SASjsApi/drive/deploy': {
|
|
75
|
-
implemented:
|
|
146
|
+
implemented: true,
|
|
147
|
+
responses: {
|
|
148
|
+
200: deploy_js_1.DEPLOY_SUCCESS,
|
|
149
|
+
400: {
|
|
150
|
+
status: 'failure',
|
|
151
|
+
message: 'Provided not supported data format.'
|
|
152
|
+
},
|
|
153
|
+
413: 'The file tree exceeds the deploy limit.',
|
|
154
|
+
500: { status: 'failure', message: 'Deployment failed!' }
|
|
155
|
+
}
|
|
76
156
|
},
|
|
77
157
|
'POST /SASjsApi/drive/deploy/upload': {
|
|
78
|
-
implemented: false
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// invert is implemented here. The statuses are the document's; `404` is a pair
|
|
84
|
-
// - the registry name and the appLoc it was published from - that matches no
|
|
85
|
-
// entry, and the drive's content is deliberately left alone.
|
|
158
|
+
implemented: false,
|
|
159
|
+
reason: ARCHIVE
|
|
160
|
+
},
|
|
161
|
+
// `404` is a pair - the registry name and the appLoc it was published from -
|
|
162
|
+
// that matches no entry, and the drive's content is deliberately left alone.
|
|
86
163
|
'DELETE /SASjsApi/drive/deploy': {
|
|
87
|
-
implemented: false
|
|
164
|
+
implemented: false,
|
|
165
|
+
reason: UNPUBLISH
|
|
88
166
|
},
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
'
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
167
|
+
// Group - members and the groups themselves
|
|
168
|
+
'GET /SASjsApi/group': { implemented: false, reason: IDENTITY_RECORDS },
|
|
169
|
+
'GET /SASjsApi/group/{uid}': { implemented: false, reason: IDENTITY_RECORDS },
|
|
170
|
+
'GET /SASjsApi/group/by/groupname/{name}': {
|
|
171
|
+
implemented: false,
|
|
172
|
+
reason: IDENTITY_RECORDS
|
|
95
173
|
},
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
responses: { 200: { accessToken: '', refreshToken: '' } }
|
|
174
|
+
'POST /SASjsApi/group': { implemented: false, reason: IDENTITY_RECORDS },
|
|
175
|
+
'POST /SASjsApi/group/{groupUid}/{userUid}': {
|
|
176
|
+
implemented: false,
|
|
177
|
+
reason: IDENTITY_RECORDS
|
|
101
178
|
},
|
|
102
|
-
'
|
|
103
|
-
implemented:
|
|
104
|
-
|
|
179
|
+
'DELETE /SASjsApi/group/{groupUid}/{userUid}': {
|
|
180
|
+
implemented: false,
|
|
181
|
+
reason: IDENTITY_RECORDS
|
|
105
182
|
},
|
|
106
|
-
'
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
responses: { 200: { code: '' } }
|
|
183
|
+
'DELETE /SASjsApi/group/{uid}': {
|
|
184
|
+
implemented: false,
|
|
185
|
+
reason: IDENTITY_RECORDS
|
|
110
186
|
},
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
187
|
+
// Info - what the host is, and what this caller may use
|
|
188
|
+
'GET /SASjsApi/info': { implemented: false, reason: HOST_CONFIG },
|
|
189
|
+
'GET /SASjsApi/info/authorizedRoutes': {
|
|
190
|
+
implemented: false,
|
|
191
|
+
// the route inventory reports what the CALLER may use, so it needs an
|
|
192
|
+
// identity even though the rest of the Info tag needs nothing
|
|
193
|
+
capabilities: ['identity'],
|
|
194
|
+
reason: PERMISSIONS
|
|
195
|
+
},
|
|
196
|
+
'GET /SASjsApi/info/myAuthorizedRoutes': {
|
|
197
|
+
implemented: false,
|
|
198
|
+
capabilities: ['identity'],
|
|
199
|
+
reason: PERMISSIONS
|
|
115
200
|
},
|
|
201
|
+
// Permission - the rules the inventory above is derived from
|
|
202
|
+
'GET /SASjsApi/permission': { implemented: false, reason: IDENTITY_RECORDS },
|
|
203
|
+
'POST /SASjsApi/permission': { implemented: false, reason: IDENTITY_RECORDS },
|
|
204
|
+
'PATCH /SASjsApi/permission/{uid}': {
|
|
205
|
+
implemented: false,
|
|
206
|
+
reason: IDENTITY_RECORDS
|
|
207
|
+
},
|
|
208
|
+
'DELETE /SASjsApi/permission/{uid}': {
|
|
209
|
+
implemented: false,
|
|
210
|
+
reason: IDENTITY_RECORDS
|
|
211
|
+
},
|
|
212
|
+
// Session - the caller's own session, then the record behind a triggered one
|
|
116
213
|
'GET /SASjsApi/session': {
|
|
117
214
|
implemented: true,
|
|
118
215
|
responses: { 200: user }
|
|
216
|
+
},
|
|
217
|
+
'GET /SASjsApi/session/{sessionId}': {
|
|
218
|
+
implemented: false,
|
|
219
|
+
reason: SESSION_RECORD
|
|
220
|
+
},
|
|
221
|
+
'GET /SASjsApi/session/{sessionId}/log': {
|
|
222
|
+
implemented: false,
|
|
223
|
+
reason: SESSION_RECORD
|
|
224
|
+
},
|
|
225
|
+
'GET /SASjsApi/session/{sessionId}/output': {
|
|
226
|
+
implemented: false,
|
|
227
|
+
reason: SESSION_RECORD
|
|
228
|
+
},
|
|
229
|
+
'GET /SASjsApi/session/{sessionId}/state': {
|
|
230
|
+
implemented: false,
|
|
231
|
+
reason: SESSION_RECORD
|
|
232
|
+
},
|
|
233
|
+
// STP - a stored program, on the runtime port
|
|
234
|
+
'GET /SASjsApi/stp/execute': { implemented: false, reason: RUNTIME },
|
|
235
|
+
'POST /SASjsApi/stp/execute': { implemented: false, reason: RUNTIME },
|
|
236
|
+
'POST /SASjsApi/stp/trigger': { implemented: false, reason: RUNTIME },
|
|
237
|
+
// User - the accounts the identity store already holds for sign-in
|
|
238
|
+
'GET /SASjsApi/user': { implemented: false, reason: IDENTITY_RECORDS },
|
|
239
|
+
'GET /SASjsApi/user/{uid}': { implemented: false, reason: IDENTITY_RECORDS },
|
|
240
|
+
'GET /SASjsApi/user/by/username/{username}': {
|
|
241
|
+
implemented: false,
|
|
242
|
+
reason: IDENTITY_RECORDS
|
|
243
|
+
},
|
|
244
|
+
'POST /SASjsApi/user': { implemented: false, reason: IDENTITY_RECORDS },
|
|
245
|
+
'PATCH /SASjsApi/user/{uid}': {
|
|
246
|
+
implemented: false,
|
|
247
|
+
reason: IDENTITY_RECORDS
|
|
248
|
+
},
|
|
249
|
+
'PATCH /SASjsApi/user/by/username/{username}': {
|
|
250
|
+
implemented: false,
|
|
251
|
+
reason: IDENTITY_RECORDS
|
|
252
|
+
},
|
|
253
|
+
'DELETE /SASjsApi/user/{uid}': {
|
|
254
|
+
implemented: false,
|
|
255
|
+
reason: IDENTITY_RECORDS
|
|
256
|
+
},
|
|
257
|
+
'DELETE /SASjsApi/user/by/username/{username}': {
|
|
258
|
+
implemented: false,
|
|
259
|
+
reason: IDENTITY_RECORDS
|
|
119
260
|
}
|
|
120
261
|
};
|
|
121
262
|
/** Every method the table may state, in the order a diff reads best. */
|
package/build/cjs/types.js
CHANGED
|
@@ -16,9 +16,19 @@ exports.isApiError = exports.ApiError = void 0;
|
|
|
16
16
|
* corpus can assert on the pair.
|
|
17
17
|
*/
|
|
18
18
|
class ApiError extends Error {
|
|
19
|
-
constructor(status, message
|
|
19
|
+
constructor(status, message,
|
|
20
|
+
/**
|
|
21
|
+
* The body the route renders instead of the bare message.
|
|
22
|
+
*
|
|
23
|
+
* Most drive routes send the message as text (`res.status(code).send(err)`),
|
|
24
|
+
* and that stays the default: a body here is for the routes that answer an
|
|
25
|
+
* envelope, which today is the two deploy routes and their
|
|
26
|
+
* `{status: 'failure', message}`.
|
|
27
|
+
*/
|
|
28
|
+
body) {
|
|
20
29
|
super(message);
|
|
21
30
|
this.status = status;
|
|
31
|
+
this.body = body;
|
|
22
32
|
/** Told apart by marker rather than `instanceof` - see `isApiError`. */
|
|
23
33
|
this.apiError = true;
|
|
24
34
|
this.name = 'ApiError';
|
|
@@ -23,6 +23,12 @@ export interface CorpusCase {
|
|
|
23
23
|
seed?: Record<string, string | Uint8Array>;
|
|
24
24
|
/** The identity the case starts from; the default fixture when omitted. */
|
|
25
25
|
identity?: IdentityFixture;
|
|
26
|
+
/**
|
|
27
|
+
* The drive refuses every write, so a case can pin the refusal a route answers
|
|
28
|
+
* when storing fails - `POST /drive/deploy` documents a `500`, and a mock drive
|
|
29
|
+
* that always accepts writes could never produce one.
|
|
30
|
+
*/
|
|
31
|
+
unwritable?: boolean;
|
|
26
32
|
request: ApiRequest;
|
|
27
33
|
expect: {
|
|
28
34
|
status: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;KAChB,CAAA;CACF;
|
|
1
|
+
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;KAChB,CAAA;CACF;AAmBD,eAAO,MAAM,YAAY,EAAE,UAAU,EAqapC,CAAA"}
|
|
@@ -5,6 +5,12 @@ const success = { status: 'success' };
|
|
|
5
5
|
* host that does not have one. An explicit byte array is both.
|
|
6
6
|
*/
|
|
7
7
|
const bytes = (...values) => Uint8Array.from(values);
|
|
8
|
+
/** A flat tree of `count` empty file members, for the cap's entry dimension. */
|
|
9
|
+
const manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
|
|
10
|
+
name: `f${at}.txt`,
|
|
11
|
+
type: 'file',
|
|
12
|
+
code: ''
|
|
13
|
+
}));
|
|
8
14
|
export const DRIVE_CORPUS = [
|
|
9
15
|
{
|
|
10
16
|
id: 'create-file',
|
|
@@ -294,6 +300,119 @@ export const DRIVE_CORPUS = [
|
|
|
294
300
|
}
|
|
295
301
|
}
|
|
296
302
|
},
|
|
303
|
+
{
|
|
304
|
+
id: 'deploy-tree',
|
|
305
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
306
|
+
description: 'writes a whole tree under the appLoc it names',
|
|
307
|
+
request: {
|
|
308
|
+
body: {
|
|
309
|
+
appLoc: '/Public/apps/demo',
|
|
310
|
+
fileTree: {
|
|
311
|
+
members: [
|
|
312
|
+
{
|
|
313
|
+
name: 'services',
|
|
314
|
+
type: 'folder',
|
|
315
|
+
members: [{ name: 'run', type: 'service', code: '%put hi;' }]
|
|
316
|
+
},
|
|
317
|
+
{ name: 'index.html', type: 'file', code: 'PGgxPkhlbGxvPC9oMT4=' }
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
expect: {
|
|
323
|
+
status: 200,
|
|
324
|
+
body: {
|
|
325
|
+
status: 'success',
|
|
326
|
+
message: 'Files deployed successfully to @sasjs/server.'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'deploy-refused-by-entry-count',
|
|
332
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
333
|
+
description: 'refuses a tree over the entry cap, naming the figure and the cap',
|
|
334
|
+
request: {
|
|
335
|
+
body: {
|
|
336
|
+
appLoc: '/Public/apps/many',
|
|
337
|
+
fileTree: { members: manyMembers(10001) }
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
expect: {
|
|
341
|
+
status: 413,
|
|
342
|
+
body: {
|
|
343
|
+
status: 'failure',
|
|
344
|
+
message: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
|
|
345
|
+
},
|
|
346
|
+
error: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: 'deploy-refused-by-name',
|
|
351
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
352
|
+
description: 'refuses a member name that would escape the deployment root',
|
|
353
|
+
request: {
|
|
354
|
+
body: {
|
|
355
|
+
appLoc: '/Public/apps/hostile',
|
|
356
|
+
fileTree: {
|
|
357
|
+
members: [{ name: '../escape', type: 'service', code: '%put hi;' }]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
expect: {
|
|
362
|
+
status: 400,
|
|
363
|
+
body: {
|
|
364
|
+
status: 'failure',
|
|
365
|
+
message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
|
|
366
|
+
},
|
|
367
|
+
error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'deploy-refused-by-shape',
|
|
372
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
373
|
+
description: 'refuses a fileTree that is not a tree, in the routes own envelope',
|
|
374
|
+
request: { body: { appLoc: '/Public/apps/bad', fileTree: { members: 1 } } },
|
|
375
|
+
expect: {
|
|
376
|
+
status: 400,
|
|
377
|
+
body: {
|
|
378
|
+
status: 'failure',
|
|
379
|
+
message: 'Provided not supported data format.'
|
|
380
|
+
},
|
|
381
|
+
error: 'Provided not supported data format.'
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: 'deploy-refused-by-store',
|
|
386
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
387
|
+
description: 'answers the routes 500 when the drive refuses the write',
|
|
388
|
+
unwritable: true,
|
|
389
|
+
request: {
|
|
390
|
+
body: {
|
|
391
|
+
appLoc: '/Public/apps/demo',
|
|
392
|
+
fileTree: {
|
|
393
|
+
members: [{ name: 'run', type: 'service', code: '%put hi;' }]
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
expect: {
|
|
398
|
+
status: 500,
|
|
399
|
+
body: { status: 'failure', message: 'Deployment failed!' },
|
|
400
|
+
error: 'Deployment failed!'
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: 'deploy-refused-outside-drive',
|
|
405
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
406
|
+
description: 'refuses an appLoc that resolves outside the drive',
|
|
407
|
+
request: {
|
|
408
|
+
body: { appLoc: '/../outside', fileTree: { members: [] } }
|
|
409
|
+
},
|
|
410
|
+
expect: {
|
|
411
|
+
status: 400,
|
|
412
|
+
body: { status: 'failure', message: 'appLoc cannot be outside drive.' },
|
|
413
|
+
error: 'appLoc cannot be outside drive.'
|
|
414
|
+
}
|
|
415
|
+
},
|
|
297
416
|
{
|
|
298
417
|
id: 'bytes-round-trip',
|
|
299
418
|
route: 'GET /SASjsApi/drive/file',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAQnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAQnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QASjD,CAAA;AAkBF;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
|
|
@@ -12,9 +12,25 @@ import { isApiError } from '../types.js';
|
|
|
12
12
|
* mid-flow can declare the tokens and codes it begins with.
|
|
13
13
|
*/
|
|
14
14
|
export const servicesFor = (testCase) => ({
|
|
15
|
-
drive: createDriveService(
|
|
15
|
+
drive: createDriveService(testCase.unwritable
|
|
16
|
+
? new RefusingDriveStore(testCase.seed || {})
|
|
17
|
+
: new MemoryDriveStore(testCase.seed || {})),
|
|
16
18
|
identity: createIdentityService(new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY))
|
|
17
19
|
});
|
|
20
|
+
/**
|
|
21
|
+
* A drive that reads but refuses every write.
|
|
22
|
+
*
|
|
23
|
+
* Only the corpus uses it: `POST /drive/deploy` answers `500` when storing
|
|
24
|
+
* fails, and a case for that answer needs a store that fails.
|
|
25
|
+
*/
|
|
26
|
+
class RefusingDriveStore extends MemoryDriveStore {
|
|
27
|
+
async write() {
|
|
28
|
+
throw new Error('the store refused the write');
|
|
29
|
+
}
|
|
30
|
+
async mkdirp() {
|
|
31
|
+
throw new Error('the store refused the write');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
/**
|
|
19
35
|
* Runs a corpus case against the drive the case seeds itself, or against
|
|
20
36
|
* services a caller supplies - that is how a host (or a failing store) is put
|
|
@@ -33,7 +49,7 @@ export const runCase = async (testCase, services = servicesFor(testCase)) => {
|
|
|
33
49
|
// only a refusal the API decided on becomes a status; anything else is a
|
|
34
50
|
// broken host, and reporting it as a 500 would hide it from the suite
|
|
35
51
|
if (isApiError(error))
|
|
36
|
-
return { status: error.status, error: error.message };
|
|
52
|
+
return { status: error.status, body: error.body, error: error.message };
|
|
37
53
|
throw error;
|
|
38
54
|
}
|
|
39
55
|
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploying a file tree, and the cap that bounds it.
|
|
3
|
+
*
|
|
4
|
+
* `POST /SASjsApi/drive/deploy` writes a whole tree in one request, which makes
|
|
5
|
+
* it the one drive operation where a caller can fill the host's storage: the
|
|
6
|
+
* others write a single file at a time, bounded by the host's upload limit. So
|
|
7
|
+
* the tree is measured and its names checked BEFORE anything is written, and a
|
|
8
|
+
* refusal leaves the drive exactly as it was.
|
|
9
|
+
*
|
|
10
|
+
* The numbers live here rather than in a host, because both hosts answer the
|
|
11
|
+
* same `413` and a corpus case pins the wording: a limit stated twice is a limit
|
|
12
|
+
* that disagrees with itself in the release nobody re-read.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here imports a runtime. A base64 file member is decoded by hand and a
|
|
15
|
+
* service's size is counted as UTF-8 by hand, because the same build runs in a
|
|
16
|
+
* service worker where `Buffer` does not exist and the package compiles against
|
|
17
|
+
* `lib: ES2020` with no ambient types.
|
|
18
|
+
*/
|
|
19
|
+
import type { DriveStore } from '../types.js';
|
|
20
|
+
/** The three member kinds the wire format carries. */
|
|
21
|
+
export type DeployMemberType = 'folder' | 'service' | 'file';
|
|
22
|
+
export interface DeployFolderMember {
|
|
23
|
+
name: string;
|
|
24
|
+
type: 'folder';
|
|
25
|
+
members: DeployMember[];
|
|
26
|
+
}
|
|
27
|
+
/** A program; written as `<name>.sas`, its `code` being the program text. */
|
|
28
|
+
export interface DeployServiceMember {
|
|
29
|
+
name: string;
|
|
30
|
+
type: 'service';
|
|
31
|
+
code: string;
|
|
32
|
+
}
|
|
33
|
+
/** A file; `code` is base64 of the bytes to write. */
|
|
34
|
+
export interface DeployFileMember {
|
|
35
|
+
name: string;
|
|
36
|
+
type: 'file';
|
|
37
|
+
code: string;
|
|
38
|
+
}
|
|
39
|
+
export type DeployMember = DeployFolderMember | DeployServiceMember | DeployFileMember;
|
|
40
|
+
export interface DeployTree {
|
|
41
|
+
members: DeployMember[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The deploy cap, in binary megabytes (the unit a host's upload limit reports)
|
|
45
|
+
* and compared inclusively: a tree of exactly 10,000 entries and exactly 100 MB
|
|
46
|
+
* passes.
|
|
47
|
+
*
|
|
48
|
+
* The three dimensions fail differently and so are separate: an estate is many
|
|
49
|
+
* small files (`entries`), a compiled service is one large file (`fileBytes`),
|
|
50
|
+
* and the total is what the host holds in memory before the tree reaches the
|
|
51
|
+
* drive (`totalBytes`).
|
|
52
|
+
*/
|
|
53
|
+
export interface DeployLimits {
|
|
54
|
+
readonly entries: number;
|
|
55
|
+
readonly totalBytes: number;
|
|
56
|
+
readonly fileBytes: number;
|
|
57
|
+
}
|
|
58
|
+
export declare const DEPLOY_LIMITS: DeployLimits;
|
|
59
|
+
/**
|
|
60
|
+
* The largest a deploy tree can be on the wire, which is what a host's transport
|
|
61
|
+
* has to admit.
|
|
62
|
+
*
|
|
63
|
+
* The cap is stated in DECODED bytes; the tree travels as JSON in which a file
|
|
64
|
+
* member's contents are base64 - 4/3 of what they write - and a service's are
|
|
65
|
+
* escaped text (~1.04). A tree at `totalBytes` therefore reaches the parser at
|
|
66
|
+
* up to ~134MB, and a transport that admits less refuses first with its own
|
|
67
|
+
* shape, so the cap it was meant to enforce is never consulted. 140MB leaves
|
|
68
|
+
* room for the tree's JSON scaffolding.
|
|
69
|
+
*
|
|
70
|
+
* Only the deploy routes use it: everywhere else a smaller limit applies,
|
|
71
|
+
* because a body is buffered before authentication and nothing else needs the
|
|
72
|
+
* headroom.
|
|
73
|
+
*/
|
|
74
|
+
export declare const DEPLOY_PAYLOAD_BYTES: number;
|
|
75
|
+
/**
|
|
76
|
+
* The body a deploy answers success with.
|
|
77
|
+
*
|
|
78
|
+
* The wording is the published contract - a client may key on the message - so
|
|
79
|
+
* it is one constant here rather than a string in each host. It names
|
|
80
|
+
* `@sasjs/server` because that is the document's example, which predates this
|
|
81
|
+
* package; changing it is a document change, not a host's choice.
|
|
82
|
+
*/
|
|
83
|
+
export declare const DEPLOY_SUCCESS: {
|
|
84
|
+
readonly status: "success";
|
|
85
|
+
readonly message: "Files deployed successfully to @sasjs/server.";
|
|
86
|
+
};
|
|
87
|
+
/** The refusal a deploy earns, in the shape the deploy routes render. */
|
|
88
|
+
export interface DeployRefusal {
|
|
89
|
+
code: 400 | 413;
|
|
90
|
+
status: 'failure';
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
export interface DeployMeasurements {
|
|
94
|
+
entries: number;
|
|
95
|
+
totalBytes: number;
|
|
96
|
+
/** The largest single member, by decoded size, with its path in the tree. */
|
|
97
|
+
largest: {
|
|
98
|
+
path: string;
|
|
99
|
+
bytes: number;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The bytes a base64 body decodes to, for a store that writes bytes. */
|
|
103
|
+
export declare const decodeBase64: (value: string) => Uint8Array;
|
|
104
|
+
/**
|
|
105
|
+
* Measures a tree without touching a drive.
|
|
106
|
+
*
|
|
107
|
+
* Every member is counted, whatever its name: this answers "how big is this
|
|
108
|
+
* tree", and a caller that also needs the names checked is `checkDeployTree`.
|
|
109
|
+
*/
|
|
110
|
+
export declare const measureDeployTree: (members: DeployMember[]) => DeployMeasurements;
|
|
111
|
+
/**
|
|
112
|
+
* The refusal a file tree earns, or `null` when it can be written.
|
|
113
|
+
*
|
|
114
|
+
* Names are checked first, then the dimensions in a fixed order - entries,
|
|
115
|
+
* total, single file - so a tree breaching two of them is refused for the same
|
|
116
|
+
* one every time, which is what lets a caller key on the message and a test pin
|
|
117
|
+
* it.
|
|
118
|
+
*
|
|
119
|
+
* `limits` defaults to `DEPLOY_LIMITS`; a host may pass its own, stricter set.
|
|
120
|
+
*/
|
|
121
|
+
export declare const checkDeployTree: (members: DeployMember[], limits?: DeployLimits) => DeployRefusal | null;
|
|
122
|
+
/**
|
|
123
|
+
* The tree's shape, as the wire format states it.
|
|
124
|
+
*
|
|
125
|
+
* A port of `isFileTree` from `@sasjs/utils`, which is what sasjs/server
|
|
126
|
+
* validates with: a tree is an object with a `members` array, and every member
|
|
127
|
+
* is a folder with its own members, or a service/file with a string `code` and
|
|
128
|
+
* a string `name`. Anything else is the `400` the routes already answer.
|
|
129
|
+
*/
|
|
130
|
+
export declare const isDeployTree: (value: unknown) => value is DeployTree;
|
|
131
|
+
/**
|
|
132
|
+
* Deploys a tree into `appLoc`, or refuses without writing.
|
|
133
|
+
*
|
|
134
|
+
* The order is the promise: shape, then containment, then the cap and the names,
|
|
135
|
+
* and only then the first write. `appLoc` is caller-supplied and every segment
|
|
136
|
+
* must stay a segment, so it is resolved against the drive root and refused when
|
|
137
|
+
* it leaves it - joined naively, a `..` would write outside the drive.
|
|
138
|
+
*
|
|
139
|
+
* A write that fails part-way answers the deploy routes' `500`; the tree before
|
|
140
|
+
* the failure is already on the drive, which the message does not hide.
|
|
141
|
+
*/
|
|
142
|
+
export declare const deployTree: (store: DriveStore, appLoc: unknown, tree: unknown, limits?: DeployLimits) => Promise<void>;
|
|
143
|
+
//# sourceMappingURL=deploy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../../src/drive/deploy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C,sDAAsD;AACtD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;AAE5D,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,YAAY,EAAE,CAAA;CACxB;AAED,6EAA6E;AAC7E,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,SAAS,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,sDAAsD;AACtD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,YAAY,GACtB,kBAAkB,GAAG,mBAAmB,GAAG,gBAAgB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,YAAY,EAAE,CAAA;CACxB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,eAAO,MAAM,aAAa,EAAE,YAI3B,CAAA;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,QAAoB,CAAA;AAErD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;CAGjB,CAAA;AAEV,yEAAyE;AACzE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,GAAG,GAAG,GAAG,CAAA;IACf,MAAM,EAAE,SAAS,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,6EAA6E;IAC7E,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAiBD,yEAAyE;AACzE,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,UAmB5C,CAAA;AA8FD;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,YAAY,EAAE,KACtB,kBAUF,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,YAAY,EAAE,EACvB,SAAQ,YAA4B,KACnC,aAAa,GAAG,IA6BlB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,UAMtD,CAAA;AAkBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,UAAU,GACrB,OAAO,UAAU,EACjB,QAAQ,OAAO,EACf,MAAM,OAAO,EACb,SAAQ,YAA4B,KACnC,OAAO,CAAC,IAAI,CAiCd,CAAA"}
|