@sasjs/api 0.2.0 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +28 -8
  2. package/build/browser.js +810 -4
  3. package/build/cjs/corpus/drive.js +20 -0
  4. package/build/cjs/corpus/identity.js +268 -0
  5. package/build/cjs/corpus/runner.js +11 -2
  6. package/build/cjs/drive/service.js +8 -0
  7. package/build/cjs/host/dispatch.js +57 -2
  8. package/build/cjs/identity/fixture.js +118 -0
  9. package/build/cjs/identity/memory-store.js +179 -0
  10. package/build/cjs/identity/service.js +142 -0
  11. package/build/cjs/identity/types.js +11 -0
  12. package/build/cjs/index.js +11 -1
  13. package/build/cjs/routes/generated.js +72 -0
  14. package/build/cjs/routes/index.js +6 -0
  15. package/build/cjs/routes/overlay.js +38 -0
  16. package/build/esm/corpus/drive.d.ts +14 -0
  17. package/build/esm/corpus/drive.d.ts.map +1 -1
  18. package/build/esm/corpus/drive.js +20 -0
  19. package/build/esm/corpus/identity.d.ts +17 -0
  20. package/build/esm/corpus/identity.d.ts.map +1 -0
  21. package/build/esm/corpus/identity.js +265 -0
  22. package/build/esm/corpus/runner.d.ts +6 -1
  23. package/build/esm/corpus/runner.d.ts.map +1 -1
  24. package/build/esm/corpus/runner.js +11 -2
  25. package/build/esm/drive/service.d.ts.map +1 -1
  26. package/build/esm/drive/service.js +9 -1
  27. package/build/esm/host/dispatch.d.ts +16 -0
  28. package/build/esm/host/dispatch.d.ts.map +1 -1
  29. package/build/esm/host/dispatch.js +57 -2
  30. package/build/esm/identity/fixture.d.ts +84 -0
  31. package/build/esm/identity/fixture.d.ts.map +1 -0
  32. package/build/esm/identity/fixture.js +114 -0
  33. package/build/esm/identity/memory-store.d.ts +51 -0
  34. package/build/esm/identity/memory-store.d.ts.map +1 -0
  35. package/build/esm/identity/memory-store.js +175 -0
  36. package/build/esm/identity/service.d.ts +48 -0
  37. package/build/esm/identity/service.d.ts.map +1 -0
  38. package/build/esm/identity/service.js +137 -0
  39. package/build/esm/identity/types.d.ts +79 -0
  40. package/build/esm/identity/types.d.ts.map +1 -0
  41. package/build/esm/identity/types.js +10 -0
  42. package/build/esm/index.d.ts +7 -0
  43. package/build/esm/index.d.ts.map +1 -1
  44. package/build/esm/index.js +4 -0
  45. package/build/esm/routes/generated.d.ts.map +1 -1
  46. package/build/esm/routes/generated.js +72 -0
  47. package/build/esm/routes/index.d.ts.map +1 -1
  48. package/build/esm/routes/index.js +6 -0
  49. package/build/esm/routes/overlay.d.ts +9 -0
  50. package/build/esm/routes/overlay.d.ts.map +1 -1
  51. package/build/esm/routes/overlay.js +38 -0
  52. package/build/esm/types.d.ts +7 -0
  53. package/build/esm/types.d.ts.map +1 -1
  54. package/package.json +9 -3
@@ -238,6 +238,26 @@ exports.DRIVE_CORPUS = [
238
238
  request: { body: { oldPath: '/data/nope.csv', newPath: '/data/x.csv' } },
239
239
  expect: { status: 404, error: 'No file/folder found for provided path.' }
240
240
  },
241
+ {
242
+ id: 'rename-file-to-blocked-extension',
243
+ route: 'POST /SASjsApi/drive/rename',
244
+ description: 'refuses a rename that would produce a name the file routes refuse',
245
+ seed: { '/data/report.csv': 'a,b' },
246
+ request: {
247
+ body: { oldPath: '/data/report.csv', newPath: '/data/report.sh' }
248
+ },
249
+ expect: { status: 400, error: 'Invalid file extension' }
250
+ },
251
+ {
252
+ id: 'rename-folder-to-dotted-name',
253
+ route: 'POST /SASjsApi/drive/rename',
254
+ description: 'renames a folder whose new name carries a blocked suffix',
255
+ seed: { '/data/reports/a.csv': 'a' },
256
+ request: {
257
+ body: { oldPath: '/data/reports', newPath: '/data/reports.sh' }
258
+ },
259
+ expect: { status: 200, body: { status: 'success' } }
260
+ },
241
261
  {
242
262
  id: 'rename-folder-into-itself',
243
263
  route: 'POST /SASjsApi/drive/rename',
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IDENTITY_CORPUS = exports.DEACTIVATED = exports.SIGNED_IN = void 0;
4
+ const fixture_js_1 = require("../identity/fixture.js");
5
+ /** The default fixture, plus a caller that is already signed in. */
6
+ exports.SIGNED_IN = {
7
+ ...fixture_js_1.DEFAULT_IDENTITY,
8
+ tokens: [
9
+ {
10
+ username: 'user2',
11
+ clientId: 'web_app',
12
+ accessToken: 'seed-access',
13
+ refreshToken: 'seed-refresh'
14
+ },
15
+ {
16
+ username: 'admin',
17
+ clientId: 'removed_client',
18
+ accessToken: 'seed-access-orphan',
19
+ refreshToken: 'seed-refresh-orphan'
20
+ }
21
+ ],
22
+ codes: [
23
+ { code: 'seed-code', username: 'user2', clientId: 'web_app' },
24
+ { code: 'seed-orphan-code', username: 'user2', clientId: 'removed_client' },
25
+ // the same shape with a pair already issued: the exchange must still refuse,
26
+ // rather than handing back tokens that belong to a client that is gone
27
+ {
28
+ code: 'seed-orphan-pair-code',
29
+ username: 'admin',
30
+ clientId: 'removed_client'
31
+ }
32
+ ]
33
+ };
34
+ /** The same identity, with the restricted user switched off. */
35
+ exports.DEACTIVATED = {
36
+ ...exports.SIGNED_IN,
37
+ users: exports.SIGNED_IN.users.map((user) => user.username === 'user2' ? { ...user, isActive: false } : user)
38
+ };
39
+ /** The user the seeded session belongs to, without the host's own identifier. */
40
+ const user2 = {
41
+ username: 'user2',
42
+ displayName: 'Demo User',
43
+ isAdmin: false,
44
+ needsToUpdatePassword: false
45
+ };
46
+ exports.IDENTITY_CORPUS = [
47
+ {
48
+ id: 'login',
49
+ route: 'POST /SASLogon/login',
50
+ description: 'signs a seeded user in',
51
+ request: { body: { username: 'admin', password: 'admin123' } },
52
+ expect: {
53
+ status: 200,
54
+ partial: {
55
+ loggedIn: true,
56
+ user: {
57
+ username: 'admin',
58
+ displayName: 'Demo Admin',
59
+ isAdmin: true,
60
+ needsToUpdatePassword: false
61
+ }
62
+ }
63
+ }
64
+ },
65
+ {
66
+ id: 'login-missing-username',
67
+ route: 'POST /SASLogon/login',
68
+ description: 'refuses a sign-in with no username as 400, naming the field',
69
+ request: { body: { password: 'admin123' } },
70
+ expect: { status: 400, error: '"username" is required' }
71
+ },
72
+ {
73
+ id: 'login-deactivated',
74
+ route: 'POST /SASLogon/login',
75
+ description: 'refuses a deactivated account, naming it',
76
+ identity: exports.DEACTIVATED,
77
+ request: { body: { username: 'user2', password: 'user123' } },
78
+ expect: { status: 401, error: 'This account is not active.' }
79
+ },
80
+ {
81
+ id: 'login-wrong-password',
82
+ route: 'POST /SASLogon/login',
83
+ description: 'refuses a wrong password as 401, naming it',
84
+ request: { body: { username: 'admin', password: 'wrong123' } },
85
+ expect: { status: 401, error: 'Invalid Password.' }
86
+ },
87
+ {
88
+ id: 'login-unknown-user',
89
+ route: 'POST /SASLogon/login',
90
+ description: 'refuses an unknown username as 401, naming it',
91
+ request: { body: { username: 'nobody', password: 'admin123' } },
92
+ expect: { status: 401, error: 'Username is not found.' }
93
+ },
94
+ {
95
+ id: 'authorize',
96
+ route: 'POST /SASLogon/authorize',
97
+ description: 'issues a code to a signed-in caller',
98
+ identity: exports.SIGNED_IN,
99
+ request: {
100
+ token: 'seed-access',
101
+ body: { clientId: 'web_app' }
102
+ },
103
+ expect: { status: 200, keys: ['code'] }
104
+ },
105
+ {
106
+ id: 'authorize-missing-client',
107
+ route: 'POST /SASLogon/authorize',
108
+ description: 'refuses an authorize with no clientId as 400, naming the field',
109
+ identity: exports.SIGNED_IN,
110
+ request: { token: 'seed-access', body: {} },
111
+ expect: { status: 400, error: '"clientId" is required' }
112
+ },
113
+ {
114
+ id: 'authorize-without-session',
115
+ route: 'POST /SASLogon/authorize',
116
+ description: 'refuses with 401 when no caller is signed in',
117
+ request: { body: { clientId: 'web_app' } },
118
+ expect: { status: 401, error: 'Unauthorized' }
119
+ },
120
+ {
121
+ id: 'authorize-unknown-client',
122
+ route: 'POST /SASLogon/authorize',
123
+ description: 'refuses an unknown client with 403',
124
+ identity: exports.SIGNED_IN,
125
+ request: { token: 'seed-access', body: { clientId: 'nope' } },
126
+ expect: { status: 403, error: 'Invalid clientId.' }
127
+ },
128
+ {
129
+ id: 'exchange-code',
130
+ route: 'POST /SASjsApi/auth/token',
131
+ description: 'exchanges a code for a token pair',
132
+ identity: exports.SIGNED_IN,
133
+ request: { body: { clientId: 'web_app', code: 'seed-code' } },
134
+ expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
135
+ },
136
+ {
137
+ id: 'exchange-missing-client',
138
+ route: 'POST /SASjsApi/auth/token',
139
+ description: 'refuses an exchange with no clientId as 400, naming the field',
140
+ identity: exports.SIGNED_IN,
141
+ request: { body: { code: 'seed-code' } },
142
+ expect: { status: 400, error: '"clientId" is required' }
143
+ },
144
+ {
145
+ id: 'exchange-code-unknown',
146
+ route: 'POST /SASjsApi/auth/token',
147
+ description: 'refuses a code the host never issued',
148
+ request: { body: { clientId: 'web_app', code: 'not-a-code' } },
149
+ expect: { status: 401, error: 'Invalid Auth Code' }
150
+ },
151
+ {
152
+ id: 'exchange-code-other-client',
153
+ route: 'POST /SASjsApi/auth/token',
154
+ description: 'refuses a code presented for another client, as a bad code',
155
+ identity: exports.SIGNED_IN,
156
+ request: { body: { clientId: 'sasjs', code: 'seed-code' } },
157
+ expect: { status: 401, error: 'Invalid Auth Code' }
158
+ },
159
+ {
160
+ id: 'exchange-code-client-gone',
161
+ route: 'POST /SASjsApi/auth/token',
162
+ description: 'refuses a valid code whose client no longer exists',
163
+ identity: exports.SIGNED_IN,
164
+ request: { body: { clientId: 'removed_client', code: 'seed-orphan-code' } },
165
+ expect: { status: 401, error: 'Invalid clientId.' }
166
+ },
167
+ {
168
+ id: 'exchange-code-client-gone-with-pair',
169
+ route: 'POST /SASjsApi/auth/token',
170
+ description: 'refuses an exchange whose client is gone even when a pair was issued before it was deleted',
171
+ identity: exports.SIGNED_IN,
172
+ request: {
173
+ body: { clientId: 'removed_client', code: 'seed-orphan-pair-code' }
174
+ },
175
+ expect: { status: 401, error: 'Invalid clientId.' }
176
+ },
177
+ {
178
+ id: 'exchange-code-deactivated',
179
+ route: 'POST /SASjsApi/auth/token',
180
+ description: 'refuses an exchange for an account that has been deactivated',
181
+ identity: exports.DEACTIVATED,
182
+ request: { body: { clientId: 'web_app', code: 'seed-code' } },
183
+ expect: { status: 401, error: 'Unauthorized' }
184
+ },
185
+ {
186
+ id: 'refresh',
187
+ route: 'POST /SASjsApi/auth/refresh',
188
+ description: 'mints a new pair from a refresh token',
189
+ identity: exports.SIGNED_IN,
190
+ request: { token: 'seed-refresh' },
191
+ expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
192
+ },
193
+ {
194
+ id: 'refresh-invalid',
195
+ route: 'POST /SASjsApi/auth/refresh',
196
+ description: 'refuses a refresh token it did not issue',
197
+ request: { token: 'not-a-token' },
198
+ expect: { status: 401, error: 'Unauthorized' }
199
+ },
200
+ {
201
+ id: 'refresh-without-token',
202
+ route: 'POST /SASjsApi/auth/refresh',
203
+ description: 'refuses a refresh with no token at all',
204
+ request: {},
205
+ expect: { status: 401, error: 'Unauthorized' }
206
+ },
207
+ {
208
+ id: 'refresh-client-gone',
209
+ route: 'POST /SASjsApi/auth/refresh',
210
+ description: 'refuses a refresh whose client no longer exists',
211
+ identity: exports.SIGNED_IN,
212
+ request: { token: 'seed-refresh-orphan' },
213
+ expect: { status: 401, error: 'Invalid clientId.' }
214
+ },
215
+ {
216
+ id: 'refresh-deactivated',
217
+ route: 'POST /SASjsApi/auth/refresh',
218
+ description: 'refuses a refresh for an account that has been deactivated, as the server does on every request',
219
+ identity: exports.DEACTIVATED,
220
+ request: { token: 'seed-refresh' },
221
+ expect: { status: 401, error: 'Unauthorized' }
222
+ },
223
+ {
224
+ id: 'logout',
225
+ route: 'POST /SASjsApi/auth/logout',
226
+ description: 'revokes the caller pair and answers 204',
227
+ identity: exports.SIGNED_IN,
228
+ request: { token: 'seed-access' },
229
+ expect: { status: 204 }
230
+ },
231
+ {
232
+ id: 'logout-without-token',
233
+ route: 'POST /SASjsApi/auth/logout',
234
+ description: 'refuses a logout with no valid token',
235
+ request: {},
236
+ expect: { status: 401, error: 'Unauthorized' }
237
+ },
238
+ {
239
+ id: 'session',
240
+ route: 'GET /SASjsApi/session',
241
+ description: 'reports the caller the token names',
242
+ identity: exports.SIGNED_IN,
243
+ request: { token: 'seed-access' },
244
+ expect: { status: 200, partial: user2 }
245
+ },
246
+ {
247
+ id: 'session-deactivated',
248
+ route: 'GET /SASjsApi/session',
249
+ description: 'stops resolving a token whose account has been deactivated',
250
+ identity: exports.DEACTIVATED,
251
+ request: { token: 'seed-access' },
252
+ expect: { status: 401, error: 'Unauthorized' }
253
+ },
254
+ {
255
+ id: 'session-without-token',
256
+ route: 'GET /SASjsApi/session',
257
+ description: 'refuses the session read with no caller',
258
+ request: {},
259
+ expect: { status: 401, error: 'Unauthorized' }
260
+ },
261
+ {
262
+ id: 'sign-out',
263
+ route: 'GET /SASLogon/logout',
264
+ description: 'answers the body the browser sign-out returns',
265
+ request: {},
266
+ expect: { status: 200, contents: 'OK!' }
267
+ }
268
+ ];
@@ -4,10 +4,19 @@ exports.runCase = exports.servicesFor = void 0;
4
4
  const dispatch_js_1 = require("../host/dispatch.js");
5
5
  const service_js_1 = require("../drive/service.js");
6
6
  const memory_store_js_1 = require("../drive/memory-store.js");
7
+ const service_js_2 = require("../identity/service.js");
8
+ const memory_store_js_2 = require("../identity/memory-store.js");
9
+ const fixture_js_1 = require("../identity/fixture.js");
7
10
  const types_js_1 = require("../types.js");
8
- /** Services over a fresh store, seeded with the case's files. */
11
+ /**
12
+ * Services over fresh stores, seeded with the case's files and identity.
13
+ *
14
+ * The identity store is built for every case, drive or not, so a case that starts
15
+ * mid-flow can declare the tokens and codes it begins with.
16
+ */
9
17
  const servicesFor = (testCase) => ({
10
- drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {}))
18
+ drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
19
+ identity: (0, service_js_2.createIdentityService)(new memory_store_js_2.MemoryIdentityStore(testCase.identity || fixture_js_1.DEFAULT_IDENTITY))
11
20
  });
12
21
  exports.servicesFor = servicesFor;
13
22
  /**
@@ -124,6 +124,14 @@ const createDriveService = (store) => {
124
124
  throw new types_js_1.ApiError(409, stat.directory
125
125
  ? 'Folder with new name already exists.'
126
126
  : 'File with new name already exists.');
127
+ // the extension rule is the drive's file-path rule, so a rename may not
128
+ // be the way around it: without this a file could be renamed into a name
129
+ // that GET, PATCH and DELETE then refuse, leaving an entry the API that
130
+ // created it can no longer read or remove. A folder is not subject to it
131
+ // (nothing puts a folder through the file-path schema), so only a file
132
+ // move is checked.
133
+ if (!stat.directory && (0, paths_js_1.hasBlockedExtension)(to))
134
+ throw new types_js_1.ApiError(400, 'Invalid file extension');
127
135
  await store.move(from, to);
128
136
  return { status: 'success' };
129
137
  }
@@ -42,8 +42,56 @@ const dispatch = async (services, routeId, request = {}) => {
42
42
  if (!route)
43
43
  throw new types_js_1.ApiError(404, `No such operation: ${routeId}`);
44
44
  validate(route, request);
45
- const { drive } = services;
45
+ const { drive, identity } = services;
46
46
  const query = request.query || {};
47
+ const body = request.body || {};
48
+ /** The caller, from the request's own subject or the token it carried. */
49
+ const caller = async () => {
50
+ if (request.subject)
51
+ return request.subject;
52
+ return identity?.authenticate(request.token);
53
+ };
54
+ if (routeId.startsWith('POST /SASjsApi/auth/') ||
55
+ routeId.startsWith('POST /SASLogon/') ||
56
+ routeId === 'GET /SASLogon/logout' ||
57
+ routeId === 'GET /SASjsApi/session') {
58
+ if (!identity)
59
+ throw new types_js_1.ApiError(501, `This host has no identity service, so it cannot answer ${routeId}.`);
60
+ switch (routeId) {
61
+ case 'POST /SASjsApi/auth/token':
62
+ return {
63
+ status: 200,
64
+ body: await identity.token({
65
+ clientId: String(body.clientId ?? ''),
66
+ code: String(body.code ?? '')
67
+ })
68
+ };
69
+ case 'POST /SASjsApi/auth/refresh':
70
+ return { status: 200, body: await identity.refresh(request.token) };
71
+ case 'POST /SASjsApi/auth/logout':
72
+ await identity.logout(await caller());
73
+ return { status: 204 };
74
+ case 'POST /SASLogon/login':
75
+ return {
76
+ status: 200,
77
+ body: await identity.login({
78
+ username: String(body.username ?? ''),
79
+ password: String(body.password ?? '')
80
+ })
81
+ };
82
+ case 'POST /SASLogon/authorize':
83
+ return {
84
+ status: 200,
85
+ body: {
86
+ code: await identity.authorize(await caller(), String(body.clientId ?? ''))
87
+ }
88
+ };
89
+ case 'GET /SASLogon/logout':
90
+ return { status: 200, contents: await identity.logoutSession() };
91
+ case 'GET /SASjsApi/session':
92
+ return { status: 200, body: await identity.session(await caller()) };
93
+ }
94
+ }
47
95
  switch (routeId) {
48
96
  case 'GET /SASjsApi/drive/file': {
49
97
  const file = await drive.read(String(query._filePath || ''));
@@ -98,5 +146,12 @@ exports.HANDLED_ROUTES = [
98
146
  'POST /SASjsApi/drive/folder',
99
147
  'DELETE /SASjsApi/drive/folder',
100
148
  'POST /SASjsApi/drive/rename',
101
- 'GET /SASjsApi/drive/filetree'
149
+ 'GET /SASjsApi/drive/filetree',
150
+ 'POST /SASjsApi/auth/token',
151
+ 'POST /SASjsApi/auth/refresh',
152
+ 'POST /SASjsApi/auth/logout',
153
+ 'POST /SASLogon/login',
154
+ 'POST /SASLogon/authorize',
155
+ 'GET /SASLogon/logout',
156
+ 'GET /SASjsApi/session'
102
157
  ];
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ /**
3
+ * The identity a browser build starts from, and the format it travels in.
4
+ *
5
+ * A shipped fixture is what makes a mock build work with no configuration: it
6
+ * is small, legible, and declared to be fake. Passwords are plain text because
7
+ * nothing verifies them beyond an equality check - there is no boundary here,
8
+ * and pretending otherwise by shipping a hash would suggest one.
9
+ *
10
+ * `format` is stamped the way the drive's snapshot is: a store written by an
11
+ * older build is refused with a named reason rather than misread.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.parseIdentity = exports.DEFAULT_IDENTITY = exports.IDENTITY_FORMAT = void 0;
15
+ exports.IDENTITY_FORMAT = 1;
16
+ const SECONDS_IN_A_DAY = 86400;
17
+ exports.DEFAULT_IDENTITY = {
18
+ format: exports.IDENTITY_FORMAT,
19
+ users: [
20
+ {
21
+ uid: 'user-admin',
22
+ username: 'admin',
23
+ displayName: 'Demo Admin',
24
+ // long enough to pass the API's own password rule (six characters), so a
25
+ // demo can sign in against a real server as well as a mock
26
+ password: 'admin123',
27
+ isAdmin: true,
28
+ isActive: true,
29
+ needsToUpdatePassword: false
30
+ },
31
+ {
32
+ uid: 'user-restricted',
33
+ username: 'user2',
34
+ displayName: 'Demo User',
35
+ password: 'user123',
36
+ isAdmin: false,
37
+ isActive: true,
38
+ needsToUpdatePassword: false
39
+ }
40
+ ],
41
+ groups: [
42
+ {
43
+ uid: 'group-data-controller',
44
+ name: 'DCGroup',
45
+ description: 'Data Controller users',
46
+ isActive: true,
47
+ users: ['user2']
48
+ },
49
+ {
50
+ uid: 'group-admins',
51
+ name: 'Admins',
52
+ description: 'Everything the API offers',
53
+ isActive: true,
54
+ users: ['admin']
55
+ }
56
+ ],
57
+ permissions: [
58
+ {
59
+ uid: 'rule-routes',
60
+ path: '/SASjsApi/info',
61
+ type: 'Route',
62
+ setting: 'Grant',
63
+ group: 'group-data-controller'
64
+ },
65
+ {
66
+ uid: 'rule-execute',
67
+ path: '/SASjsApi/stp/execute',
68
+ type: 'Route',
69
+ setting: 'Grant',
70
+ group: 'group-data-controller'
71
+ },
72
+ {
73
+ uid: 'rule-code',
74
+ path: '/SASjsApi/code/execute',
75
+ type: 'Route',
76
+ setting: 'Deny',
77
+ user: 'user-restricted'
78
+ }
79
+ ],
80
+ clients: [
81
+ {
82
+ clientId: 'web_app',
83
+ clientSecret: 'web_app',
84
+ accessTokenExpiration: SECONDS_IN_A_DAY,
85
+ refreshTokenExpiration: SECONDS_IN_A_DAY * 30
86
+ },
87
+ {
88
+ clientId: 'sasjs',
89
+ clientSecret: 'sasjs',
90
+ accessTokenExpiration: 3600,
91
+ refreshTokenExpiration: SECONDS_IN_A_DAY
92
+ }
93
+ ]
94
+ };
95
+ /**
96
+ * Parses a fixture a build wrote, refusing one this package cannot read.
97
+ *
98
+ * The checks name what is wrong and what to do about it: a fixture is hand-
99
+ * editable, so "invalid identity" would send the reader to the type rather than
100
+ * to the line.
101
+ */
102
+ const parseIdentity = (raw) => {
103
+ const fixture = raw;
104
+ if (!fixture || typeof fixture !== 'object')
105
+ throw new Error('The identity fixture is not an object.');
106
+ if (fixture.format !== exports.IDENTITY_FORMAT)
107
+ throw new Error(`The identity fixture states format ${String(fixture.format)}, and this build reads format ${exports.IDENTITY_FORMAT}. Rebuild the site, or replace mock-identity.json with one this version wrote.`);
108
+ for (const key of ['users', 'groups', 'permissions', 'clients'])
109
+ if (!Array.isArray(fixture[key]))
110
+ throw new Error(`The identity fixture has no \`${key}\` array.`);
111
+ for (const token of fixture.tokens || [])
112
+ if (!token.accessToken || !token.refreshToken)
113
+ throw new Error(`The identity fixture declares a half pair for ${token.username}/${token.clientId}: a seeded token needs both an accessToken and a refreshToken, because a token only authenticates while the subject's pair holds it.`);
114
+ if (!fixture.users.some((user) => user.isAdmin))
115
+ throw new Error('The identity fixture declares no administrator: a demo with nobody able to administer it cannot exercise the admin routes.');
116
+ return fixture;
117
+ };
118
+ exports.parseIdentity = parseIdentity;