@sasjs/api 0.11.0 → 0.13.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.
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SESSION_CORPUS = void 0;
4
4
  const ADMIN = { userId: 'user-admin', clientId: 'web_app' };
5
5
  const RESTRICTED = { userId: 'user-restricted', clientId: 'web_app' };
6
+ const notFound = (id) => `Session with ID '${id}' was not found.`;
7
+ const UNAUTHORIZED = 'Unauthorized';
6
8
  exports.SESSION_CORPUS = [
9
+ // ---------------------------------------------------------------- summary
7
10
  {
8
11
  id: 'session-summary-own',
9
12
  route: 'GET /SASjsApi/session/{sessionId}',
@@ -36,6 +39,28 @@ exports.SESSION_CORPUS = [
36
39
  }
37
40
  }
38
41
  },
42
+ {
43
+ id: 'session-summary-unauthenticated',
44
+ route: 'GET /SASjsApi/session/{sessionId}',
45
+ description: 'refuses a caller with no token',
46
+ request: { params: { sessionId: 'sess-admin' } },
47
+ expect: { status: 401, error: UNAUTHORIZED }
48
+ },
49
+ {
50
+ id: 'session-summary-foreign',
51
+ route: 'GET /SASjsApi/session/{sessionId}',
52
+ description: "answers 404 for another account's session, not 403",
53
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
54
+ expect: { status: 404, error: notFound('sess-admin') }
55
+ },
56
+ {
57
+ id: 'session-summary-unknown',
58
+ route: 'GET /SASjsApi/session/{sessionId}',
59
+ description: 'answers the same 404 for a session that does not exist',
60
+ request: { subject: ADMIN, params: { sessionId: 'sess-nope' } },
61
+ expect: { status: 404, error: notFound('sess-nope') }
62
+ },
63
+ // -------------------------------------------------------------------- log
39
64
  {
40
65
  id: 'session-log-own',
41
66
  route: 'GET /SASjsApi/session/{sessionId}/log',
@@ -53,6 +78,21 @@ exports.SESSION_CORPUS = [
53
78
  request: { subject: ADMIN, params: { sessionId: 'sess-silent' } },
54
79
  expect: { status: 200, contents: '' }
55
80
  },
81
+ {
82
+ id: 'session-log-unauthenticated',
83
+ route: 'GET /SASjsApi/session/{sessionId}/log',
84
+ description: 'refuses a caller with no token',
85
+ request: { params: { sessionId: 'sess-admin' } },
86
+ expect: { status: 401, error: UNAUTHORIZED }
87
+ },
88
+ {
89
+ id: 'session-log-foreign',
90
+ route: 'GET /SASjsApi/session/{sessionId}/log',
91
+ description: "will not read another account's log",
92
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
93
+ expect: { status: 404, error: notFound('sess-admin') }
94
+ },
95
+ // ----------------------------------------------------------------- output
56
96
  {
57
97
  id: 'session-output-own',
58
98
  route: 'GET /SASjsApi/session/{sessionId}/output',
@@ -60,6 +100,21 @@ exports.SESSION_CORPUS = [
60
100
  request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
61
101
  expect: { status: 200, contents: '<html><body>42</body></html>' }
62
102
  },
103
+ {
104
+ id: 'session-output-unauthenticated',
105
+ route: 'GET /SASjsApi/session/{sessionId}/output',
106
+ description: 'refuses a caller with no token',
107
+ request: { params: { sessionId: 'sess-admin' } },
108
+ expect: { status: 401, error: UNAUTHORIZED }
109
+ },
110
+ {
111
+ id: 'session-output-foreign',
112
+ route: 'GET /SASjsApi/session/{sessionId}/output',
113
+ description: "will not read another account's webout",
114
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
115
+ expect: { status: 404, error: notFound('sess-admin') }
116
+ },
117
+ // ------------------------------------------------------------------ state
63
118
  {
64
119
  id: 'session-state-own',
65
120
  route: 'GET /SASjsApi/session/{sessionId}/state',
@@ -69,5 +124,19 @@ exports.SESSION_CORPUS = [
69
124
  status: 200,
70
125
  body: { state: 'running', pid: 4243, lineCount: 3 }
71
126
  }
127
+ },
128
+ {
129
+ id: 'session-state-unauthenticated',
130
+ route: 'GET /SASjsApi/session/{sessionId}/state',
131
+ description: 'refuses a caller with no token',
132
+ request: { params: { sessionId: 'sess-restricted' } },
133
+ expect: { status: 401, error: UNAUTHORIZED }
134
+ },
135
+ {
136
+ id: 'session-state-foreign',
137
+ route: 'GET /SASjsApi/session/{sessionId}/state',
138
+ description: "will not report another account's session state",
139
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
140
+ expect: { status: 404, error: notFound('sess-admin') }
72
141
  }
73
142
  ];
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryAppStreamRegistry = exports.unpublishAppStream = void 0;
4
+ const types_js_1 = require("../types.js");
5
+ /**
6
+ * `DELETE /SASjsApi/drive/deploy`.
7
+ *
8
+ * The document requires both body values, so both are checked - a caller that
9
+ * names a stream but not the folder has not said what to remove. Only the folder
10
+ * reaches the registry, because that is what identifies the app.
11
+ *
12
+ * The checks are here rather than in the transport because this is the one
13
+ * implementation of the operation: a host calling it directly gets the same
14
+ * refusals in the same order, and with the same messages the document states.
15
+ */
16
+ const unpublishAppStream = async (registry, body) => {
17
+ if (!body?.streamServiceName)
18
+ throw new types_js_1.ApiError(400, '"streamServiceName" is required');
19
+ if (!body.appLoc)
20
+ throw new types_js_1.ApiError(400, '"appLoc" is required');
21
+ if (!(await registry.unpublish(body.appLoc)))
22
+ throw new types_js_1.ApiError(404, 'No such app stream entry.');
23
+ return { status: 'success' };
24
+ };
25
+ exports.unpublishAppStream = unpublishAppStream;
26
+ /**
27
+ * The registry the corpus and the package's own tests read: one published app,
28
+ * identified by its folder alone.
29
+ */
30
+ class MemoryAppStreamRegistry {
31
+ constructor(folders = ['/Public/myApp']) {
32
+ this.folders = new Set(folders);
33
+ }
34
+ async unpublish(appLoc) {
35
+ return this.folders.delete(appLoc);
36
+ }
37
+ }
38
+ exports.MemoryAppStreamRegistry = MemoryAppStreamRegistry;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HANDLED_ROUTES = exports.dispatch = void 0;
4
4
  const index_js_1 = require("../routes/index.js");
5
+ const registry_js_1 = require("../drive/registry.js");
5
6
  const types_js_1 = require("../types.js");
6
7
  const deploy_js_1 = require("../drive/deploy.js");
7
8
  /** The defaults sasjs/server's Client schema applies to an omitted lifetime. */
@@ -169,12 +170,24 @@ const dispatch = async (services, routeId, request = {}) => {
169
170
  status: 200,
170
171
  body: await drive.rename(String(request.body?.oldPath || ''), String(request.body?.newPath || ''))
171
172
  };
173
+ // The archive is the host's transport - multer and a zip library on a
174
+ // server, `DecompressionStream` in a worker - and it hands this the same tree
175
+ // `POST /deploy` takes. The deploy cap is measured on that tree, after
176
+ // expansion, which is why the two share this path rather than reading bytes.
177
+ case 'POST /SASjsApi/drive/deploy/upload':
172
178
  case 'POST /SASjsApi/drive/deploy': {
173
179
  // `streamWebFolder` is the host's: publishing an app stream is not a drive
174
180
  // operation, and a host that has no app stream ignores it.
175
181
  await drive.deploy(String(body.appLoc ?? ''), body.fileTree);
176
182
  return { status: 200, body: deploy_js_1.DEPLOY_SUCCESS };
177
183
  }
184
+ case 'DELETE /SASjsApi/drive/deploy':
185
+ if (!services.registry)
186
+ throw new types_js_1.ApiError(501, `This host has no app stream registry, so it cannot answer ${routeId}.`);
187
+ return {
188
+ status: 200,
189
+ body: await (0, registry_js_1.unpublishAppStream)(services.registry, body)
190
+ };
178
191
  case 'GET /SASjsApi/drive/filetree':
179
192
  return { status: 200, body: await drive.tree(query._folderPath) };
180
193
  // ------------------------------------------------------------- sessions
@@ -388,6 +401,8 @@ const dispatch = async (services, routeId, request = {}) => {
388
401
  exports.dispatch = dispatch;
389
402
  /** Whether a route is one `dispatch` can answer today. */
390
403
  exports.HANDLED_ROUTES = [
404
+ 'POST /SASjsApi/drive/deploy/upload',
405
+ 'DELETE /SASjsApi/drive/deploy',
391
406
  'GET /SASjsApi/session/{sessionId}',
392
407
  'GET /SASjsApi/session/{sessionId}/log',
393
408
  'GET /SASjsApi/session/{sessionId}/output',
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.servicesFor = exports.runCase = exports.PROVIDER_MANAGED = exports.ADMIN_SIGNED_IN = exports.RECORD_CORPUS = exports.SESSION_CORPUS = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.CODE_CORPUS = exports.sanitiseDisplayName = exports.createRecordService = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.measureDeployTree = exports.isDeployTree = exports.deployTree = exports.decodeBase64 = exports.checkDeployTree = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
17
+ exports.servicesFor = exports.runCase = exports.PROVIDER_MANAGED = exports.ADMIN_SIGNED_IN = exports.RECORD_CORPUS = exports.SESSION_CORPUS = exports.DEPLOY_CORPUS = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.CODE_CORPUS = exports.sanitiseDisplayName = exports.createRecordService = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.measureDeployTree = exports.isDeployTree = exports.deployTree = exports.decodeBase64 = exports.checkDeployTree = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
18
18
  /**
19
19
  * `@sasjs/api` - the SASjs Server API described once.
20
20
  *
@@ -79,6 +79,8 @@ var drive_js_1 = require("./corpus/drive.js");
79
79
  Object.defineProperty(exports, "DRIVE_CORPUS", { enumerable: true, get: function () { return drive_js_1.DRIVE_CORPUS; } });
80
80
  var identity_js_1 = require("./corpus/identity.js");
81
81
  Object.defineProperty(exports, "IDENTITY_CORPUS", { enumerable: true, get: function () { return identity_js_1.IDENTITY_CORPUS; } });
82
+ var deploy_js_2 = require("./corpus/deploy.js");
83
+ Object.defineProperty(exports, "DEPLOY_CORPUS", { enumerable: true, get: function () { return deploy_js_2.DEPLOY_CORPUS; } });
82
84
  var session_js_1 = require("./corpus/session.js");
83
85
  Object.defineProperty(exports, "SESSION_CORPUS", { enumerable: true, get: function () { return session_js_1.SESSION_CORPUS; } });
84
86
  var records_js_2 = require("./corpus/records.js");
@@ -1301,6 +1301,14 @@ exports.GENERATED_ROUTES = [
1301
1301
  {
1302
1302
  status: 200,
1303
1303
  description: 'Ok'
1304
+ },
1305
+ {
1306
+ status: 401,
1307
+ description: 'No valid access token or session was supplied'
1308
+ },
1309
+ {
1310
+ status: 404,
1311
+ description: 'Session with the given ID was not found.'
1304
1312
  }
1305
1313
  ]
1306
1314
  },
@@ -1323,6 +1331,14 @@ exports.GENERATED_ROUTES = [
1323
1331
  {
1324
1332
  status: 200,
1325
1333
  description: 'Ok'
1334
+ },
1335
+ {
1336
+ status: 401,
1337
+ description: 'No valid access token or session was supplied'
1338
+ },
1339
+ {
1340
+ status: 404,
1341
+ description: 'Session with the given ID was not found.'
1326
1342
  }
1327
1343
  ]
1328
1344
  },
@@ -1345,6 +1361,14 @@ exports.GENERATED_ROUTES = [
1345
1361
  {
1346
1362
  status: 200,
1347
1363
  description: 'Ok'
1364
+ },
1365
+ {
1366
+ status: 401,
1367
+ description: 'No valid access token or session was supplied'
1368
+ },
1369
+ {
1370
+ status: 404,
1371
+ description: 'Session with the given ID was not found.'
1348
1372
  }
1349
1373
  ]
1350
1374
  },
@@ -1367,6 +1391,14 @@ exports.GENERATED_ROUTES = [
1367
1391
  {
1368
1392
  status: 200,
1369
1393
  description: 'Ok'
1394
+ },
1395
+ {
1396
+ status: 401,
1397
+ description: 'No valid access token or session was supplied'
1398
+ },
1399
+ {
1400
+ status: 404,
1401
+ description: 'Session with the given ID was not found.'
1370
1402
  }
1371
1403
  ]
1372
1404
  },
@@ -30,8 +30,6 @@ const RUNTIME = 'Starting a program needs a runtime port, which this package doe
30
30
  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.";
31
31
  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.";
32
32
  const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
33
- 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.";
34
- const UNPUBLISH = 'Unpublishing inverts the deploy route, so the two land together.';
35
33
  const success = { status: 'success' };
36
34
  const user = {
37
35
  uid: '',
@@ -159,16 +157,10 @@ exports.OVERLAY = {
159
157
  500: { status: 'failure', message: 'Deployment failed!' }
160
158
  }
161
159
  },
162
- 'POST /SASjsApi/drive/deploy/upload': {
163
- implemented: false,
164
- reason: ARCHIVE
165
- },
160
+ 'POST /SASjsApi/drive/deploy/upload': { implemented: true },
166
161
  // `404` is a pair - the registry name and the appLoc it was published from -
167
162
  // that matches no entry, and the drive's content is deliberately left alone.
168
- 'DELETE /SASjsApi/drive/deploy': {
169
- implemented: false,
170
- reason: UNPUBLISH
171
- },
163
+ 'DELETE /SASjsApi/drive/deploy': { implemented: true },
172
164
  // Group - members and the groups themselves
173
165
  'GET /SASjsApi/group': { implemented: true },
174
166
  'GET /SASjsApi/group/{uid}': { implemented: true },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The deploy routes' conformance corpus.
3
+ *
4
+ * `DELETE /SASjsApi/drive/deploy` documents three answers - `200`, `400` and
5
+ * `404` - and each has a case here, which is what
6
+ * `spec/corpus-parity.spec.ts` enforces in both directions.
7
+ *
8
+ * **The folder is the identity.** An app is one thing to deploy and one thing to
9
+ * remove; its name is a label on it. The document requires both body values, so a
10
+ * caller must supply both, but only the folder decides what is deleted - and the
11
+ * case below with a stale name and the right folder pins exactly that, because a
12
+ * name-keyed lookup would answer 404 there and leave the app in place.
13
+ */
14
+ import type { CorpusCase } from './drive.js';
15
+ export declare const DEPLOY_CORPUS: CorpusCase[];
16
+ //# sourceMappingURL=deploy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../../src/corpus/deploy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAW5C,eAAO,MAAM,aAAa,EAAE,UAAU,EAmIrC,CAAA"}
@@ -0,0 +1,136 @@
1
+ const SUBJECT = { userId: 'user-admin', clientId: 'web_app' };
2
+ /** A tree of `count` file members, for the deploy cap. */
3
+ const manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
4
+ name: `file-${at}.txt`,
5
+ type: 'file',
6
+ code: ''
7
+ }));
8
+ export const DEPLOY_CORPUS = [
9
+ {
10
+ id: 'deploy-unpublish',
11
+ route: 'DELETE /SASjsApi/drive/deploy',
12
+ description: 'removes a published app stream and answers the status',
13
+ request: {
14
+ subject: SUBJECT,
15
+ body: { streamServiceName: 'myApp', appLoc: '/Public/myApp' }
16
+ },
17
+ expect: { status: 200, body: { status: 'success' } }
18
+ },
19
+ {
20
+ id: 'deploy-unpublish-other-name',
21
+ route: 'DELETE /SASjsApi/drive/deploy',
22
+ description: 'removes the app by its folder when the name has been renamed',
23
+ request: {
24
+ subject: SUBJECT,
25
+ body: { streamServiceName: 'renamed-app', appLoc: '/Public/myApp' }
26
+ },
27
+ expect: { status: 200, body: { status: 'success' } }
28
+ },
29
+ {
30
+ id: 'deploy-unpublish-no-stream-name',
31
+ route: 'DELETE /SASjsApi/drive/deploy',
32
+ description: 'refuses a body that does not say which stream to remove',
33
+ request: { subject: SUBJECT, body: { appLoc: '/Public/myApp' } },
34
+ expect: { status: 400, error: '"streamServiceName" is required' }
35
+ },
36
+ {
37
+ id: 'deploy-unpublish-no-app-loc',
38
+ route: 'DELETE /SASjsApi/drive/deploy',
39
+ description: 'refuses a body that does not say which folder to remove',
40
+ request: { subject: SUBJECT, body: { streamServiceName: 'myApp' } },
41
+ expect: { status: 400, error: '"appLoc" is required' }
42
+ },
43
+ {
44
+ id: 'deploy-unpublish-unknown-folder',
45
+ route: 'DELETE /SASjsApi/drive/deploy',
46
+ description: 'answers 404 for a folder nothing is published at',
47
+ request: {
48
+ subject: SUBJECT,
49
+ body: { streamServiceName: 'myApp', appLoc: '/Public/not-an-app' }
50
+ },
51
+ expect: { status: 404, error: 'No such app stream entry.' }
52
+ },
53
+ // ------------------------------------------- the uploaded-archive route
54
+ // The archive is expanded by the host, so what arrives here is the same tree
55
+ // `POST /deploy` takes - which is why every answer below matches its twin.
56
+ {
57
+ id: 'upload-tree',
58
+ route: 'POST /SASjsApi/drive/deploy/upload',
59
+ description: 'writes the tree an uploaded archive expanded to',
60
+ request: {
61
+ subject: SUBJECT,
62
+ body: {
63
+ appLoc: '/Public/apps/uploaded',
64
+ fileTree: {
65
+ members: [
66
+ { name: 'index.html', type: 'file', code: 'PGgxPkhlbGxvPC9oMT4=' }
67
+ ]
68
+ }
69
+ }
70
+ },
71
+ expect: {
72
+ status: 200,
73
+ body: {
74
+ status: 'success',
75
+ message: 'Files deployed successfully to @sasjs/server.'
76
+ }
77
+ }
78
+ },
79
+ {
80
+ id: 'upload-refused-by-shape',
81
+ route: 'POST /SASjsApi/drive/deploy/upload',
82
+ description: 'refuses an expansion that is not a tree',
83
+ request: {
84
+ subject: SUBJECT,
85
+ body: { appLoc: '/Public/apps/bad', fileTree: { members: 1 } }
86
+ },
87
+ expect: {
88
+ status: 400,
89
+ body: {
90
+ status: 'failure',
91
+ message: 'Provided not supported data format.'
92
+ },
93
+ error: 'Provided not supported data format.'
94
+ }
95
+ },
96
+ {
97
+ id: 'upload-refused-by-entry-count',
98
+ route: 'POST /SASjsApi/drive/deploy/upload',
99
+ description: 'applies the deploy cap to the expanded tree, not the bytes',
100
+ request: {
101
+ subject: SUBJECT,
102
+ body: {
103
+ appLoc: '/Public/apps/many',
104
+ fileTree: { members: manyMembers(10001) }
105
+ }
106
+ },
107
+ expect: {
108
+ status: 413,
109
+ body: {
110
+ status: 'failure',
111
+ message: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
112
+ },
113
+ error: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
114
+ }
115
+ },
116
+ {
117
+ id: 'upload-refused-by-store',
118
+ route: 'POST /SASjsApi/drive/deploy/upload',
119
+ description: 'answers the routes 500 when the drive refuses the write',
120
+ unwritable: true,
121
+ request: {
122
+ subject: SUBJECT,
123
+ body: {
124
+ appLoc: '/Public/apps/demo',
125
+ fileTree: {
126
+ members: [{ name: 'run', type: 'service', code: '%put hi;' }]
127
+ }
128
+ }
129
+ },
130
+ expect: {
131
+ status: 500,
132
+ body: { status: 'failure', message: 'Deployment failed!' },
133
+ error: 'Deployment failed!'
134
+ }
135
+ }
136
+ ];
@@ -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;AAWnD,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,QAmBlD,CAAA;AAkBD;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
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;AAYnD,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,QAoBlD,CAAA;AAkBD;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
@@ -1,6 +1,7 @@
1
1
  import { dispatch } from '../host/dispatch.js';
2
2
  import { createDriveService } from '../drive/service.js';
3
3
  import { MemoryDriveStore } from '../drive/memory-store.js';
4
+ import { MemoryAppStreamRegistry } from '../drive/registry.js';
4
5
  import { createIdentityService } from '../identity/service.js';
5
6
  import { createRecordService } from '../identity/records.js';
6
7
  import { createSessionService } from '../session/service.js';
@@ -26,6 +27,7 @@ export const servicesFor = (testCase) => {
26
27
  : new MemoryDriveStore(testCase.seed || {})),
27
28
  identity: createIdentityService(identity),
28
29
  records: createRecordService(identity),
30
+ registry: new MemoryAppStreamRegistry(),
29
31
  session: createSessionService(identity, new MemorySessionHost())
30
32
  };
31
33
  };
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * The session routes' conformance corpus.
3
3
  *
4
- * Only the `200` answers are here, and that is a statement about the document
5
- * rather than about the routes: sasjs/server documents these four with a `200`
6
- * and nothing else, so `spec/corpus-parity.spec.ts` refuses a case that pins a
7
- * refusal. Their `401` and the `404` that covers both "no such session" and
8
- * "not yours" are pinned in `spec/session.spec.ts` instead, which the parity
9
- * gate does not govern.
4
+ * Every status the document states for these four routes has a case, in both
5
+ * directions - that is what `spec/corpus-parity.spec.ts` enforces. The refusals
6
+ * arrived here only after sasjs/server#574 documented them: until then the
7
+ * package answered a 401 and a 404 that the document did not state, and a case
8
+ * pinning one would have been called drift.
10
9
  *
11
- * The routes are in `documentRefusals.baseline.json` upstream; when they carry
12
- * their `@Response(401)` and `@Response(404)`, those cases move here.
10
+ * `404` covers an unknown session and someone else's alike, on purpose - the id
11
+ * is guessable within a window, so confirming that it exists would say the id
12
+ * was right. The cases below keep the two apart so a change that starts
13
+ * answering `403` for a foreign session is visible.
13
14
  */
14
15
  import type { CorpusCase } from './drive.js';
15
16
  export declare const SESSION_CORPUS: CorpusCase[];
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/corpus/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAK5C,eAAO,MAAM,cAAc,EAAE,UAAU,EAmEtC,CAAA"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/corpus/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAQ5C,eAAO,MAAM,cAAc,EAAE,UAAU,EAyItC,CAAA"}
@@ -1,6 +1,9 @@
1
1
  const ADMIN = { userId: 'user-admin', clientId: 'web_app' };
2
2
  const RESTRICTED = { userId: 'user-restricted', clientId: 'web_app' };
3
+ const notFound = (id) => `Session with ID '${id}' was not found.`;
4
+ const UNAUTHORIZED = 'Unauthorized';
3
5
  export const SESSION_CORPUS = [
6
+ // ---------------------------------------------------------------- summary
4
7
  {
5
8
  id: 'session-summary-own',
6
9
  route: 'GET /SASjsApi/session/{sessionId}',
@@ -33,6 +36,28 @@ export const SESSION_CORPUS = [
33
36
  }
34
37
  }
35
38
  },
39
+ {
40
+ id: 'session-summary-unauthenticated',
41
+ route: 'GET /SASjsApi/session/{sessionId}',
42
+ description: 'refuses a caller with no token',
43
+ request: { params: { sessionId: 'sess-admin' } },
44
+ expect: { status: 401, error: UNAUTHORIZED }
45
+ },
46
+ {
47
+ id: 'session-summary-foreign',
48
+ route: 'GET /SASjsApi/session/{sessionId}',
49
+ description: "answers 404 for another account's session, not 403",
50
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
51
+ expect: { status: 404, error: notFound('sess-admin') }
52
+ },
53
+ {
54
+ id: 'session-summary-unknown',
55
+ route: 'GET /SASjsApi/session/{sessionId}',
56
+ description: 'answers the same 404 for a session that does not exist',
57
+ request: { subject: ADMIN, params: { sessionId: 'sess-nope' } },
58
+ expect: { status: 404, error: notFound('sess-nope') }
59
+ },
60
+ // -------------------------------------------------------------------- log
36
61
  {
37
62
  id: 'session-log-own',
38
63
  route: 'GET /SASjsApi/session/{sessionId}/log',
@@ -50,6 +75,21 @@ export const SESSION_CORPUS = [
50
75
  request: { subject: ADMIN, params: { sessionId: 'sess-silent' } },
51
76
  expect: { status: 200, contents: '' }
52
77
  },
78
+ {
79
+ id: 'session-log-unauthenticated',
80
+ route: 'GET /SASjsApi/session/{sessionId}/log',
81
+ description: 'refuses a caller with no token',
82
+ request: { params: { sessionId: 'sess-admin' } },
83
+ expect: { status: 401, error: UNAUTHORIZED }
84
+ },
85
+ {
86
+ id: 'session-log-foreign',
87
+ route: 'GET /SASjsApi/session/{sessionId}/log',
88
+ description: "will not read another account's log",
89
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
90
+ expect: { status: 404, error: notFound('sess-admin') }
91
+ },
92
+ // ----------------------------------------------------------------- output
53
93
  {
54
94
  id: 'session-output-own',
55
95
  route: 'GET /SASjsApi/session/{sessionId}/output',
@@ -57,6 +97,21 @@ export const SESSION_CORPUS = [
57
97
  request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
58
98
  expect: { status: 200, contents: '<html><body>42</body></html>' }
59
99
  },
100
+ {
101
+ id: 'session-output-unauthenticated',
102
+ route: 'GET /SASjsApi/session/{sessionId}/output',
103
+ description: 'refuses a caller with no token',
104
+ request: { params: { sessionId: 'sess-admin' } },
105
+ expect: { status: 401, error: UNAUTHORIZED }
106
+ },
107
+ {
108
+ id: 'session-output-foreign',
109
+ route: 'GET /SASjsApi/session/{sessionId}/output',
110
+ description: "will not read another account's webout",
111
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
112
+ expect: { status: 404, error: notFound('sess-admin') }
113
+ },
114
+ // ------------------------------------------------------------------ state
60
115
  {
61
116
  id: 'session-state-own',
62
117
  route: 'GET /SASjsApi/session/{sessionId}/state',
@@ -66,5 +121,19 @@ export const SESSION_CORPUS = [
66
121
  status: 200,
67
122
  body: { state: 'running', pid: 4243, lineCount: 3 }
68
123
  }
124
+ },
125
+ {
126
+ id: 'session-state-unauthenticated',
127
+ route: 'GET /SASjsApi/session/{sessionId}/state',
128
+ description: 'refuses a caller with no token',
129
+ request: { params: { sessionId: 'sess-restricted' } },
130
+ expect: { status: 401, error: UNAUTHORIZED }
131
+ },
132
+ {
133
+ id: 'session-state-foreign',
134
+ route: 'GET /SASjsApi/session/{sessionId}/state',
135
+ description: "will not report another account's session state",
136
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-admin' } },
137
+ expect: { status: 404, error: notFound('sess-admin') }
69
138
  }
70
139
  ];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The app streams a host has published, as the API needs to unpublish one.
3
+ *
4
+ * A published stream is the host's own record - a directory convention, a
5
+ * registry table, whatever it uses to know that `/Public/myApp` is served at
6
+ * `/Public/myApp/index.html`. The package cannot invent it, so the route takes
7
+ * this port and answers 501 without it, the same way the session routes do.
8
+ */
9
+ export interface AppStreamRegistry {
10
+ /**
11
+ * Removes the app published at `appLoc`.
12
+ *
13
+ * **The folder is the identity.** An app is one thing to deploy and one thing
14
+ * to delete, and what it is called is a label on it: two apps can share a
15
+ * folder only as a special case, and they are still one thing to load or
16
+ * remove. So the lookup is by folder, and a name that no longer matches the
17
+ * one recorded does not turn a correct deletion into a `404`.
18
+ *
19
+ * `false` when nothing is published at that folder, which the route reports as
20
+ * a 404 - a delete that silently succeeded on a folder nobody deployed would
21
+ * hide a caller's typo.
22
+ */
23
+ unpublish(appLoc: string): Promise<boolean>;
24
+ }
25
+ export interface UnpublishBody {
26
+ streamServiceName?: string;
27
+ appLoc?: string;
28
+ }
29
+ /**
30
+ * `DELETE /SASjsApi/drive/deploy`.
31
+ *
32
+ * The document requires both body values, so both are checked - a caller that
33
+ * names a stream but not the folder has not said what to remove. Only the folder
34
+ * reaches the registry, because that is what identifies the app.
35
+ *
36
+ * The checks are here rather than in the transport because this is the one
37
+ * implementation of the operation: a host calling it directly gets the same
38
+ * refusals in the same order, and with the same messages the document states.
39
+ */
40
+ export declare const unpublishAppStream: (registry: AppStreamRegistry, body: UnpublishBody | undefined) => Promise<{
41
+ status: "success";
42
+ }>;
43
+ /**
44
+ * The registry the corpus and the package's own tests read: one published app,
45
+ * identified by its folder alone.
46
+ */
47
+ export declare class MemoryAppStreamRegistry implements AppStreamRegistry {
48
+ private readonly folders;
49
+ constructor(folders?: string[]);
50
+ unpublish(appLoc: string): Promise<boolean>;
51
+ }
52
+ //# sourceMappingURL=registry.d.ts.map