@voxgig/build 4.3.0 → 4.11.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 (100) hide show
  1. package/README.md +51 -2
  2. package/build.ts +20 -0
  3. package/dist/api/api_gen.d.ts +7 -0
  4. package/dist/api/api_gen.js +370 -0
  5. package/dist/api/api_gen.js.map +1 -0
  6. package/dist/build.d.ts +16 -1
  7. package/dist/build.js +17 -1
  8. package/dist/build.js.map +1 -1
  9. package/dist/doc/doc_gen.d.ts +8 -0
  10. package/dist/doc/doc_gen.js +397 -0
  11. package/dist/doc/doc_gen.js.map +1 -0
  12. package/dist/env/web/web_gen.js +159 -1
  13. package/dist/env/web/web_gen.js.map +1 -1
  14. package/dist/shape/ent.js +4 -2
  15. package/dist/shape/ent.js.map +1 -1
  16. package/env/web/web_gen.ts +174 -1
  17. package/package.json +6 -9
  18. package/tm/web/backend/env/shared/seed.ts.frag +23 -0
  19. package/tm/web/backend/env/web/api.ts.frag +157 -0
  20. package/tm/web/backend/env/web/web.ts.frag +37 -4
  21. package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
  22. package/tm/web/backend/srv/api/expose.ts.frag +33 -0
  23. package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
  24. package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
  25. package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
  26. package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
  27. package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
  28. package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
  29. package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
  30. package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
  31. package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
  32. package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
  33. package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
  34. package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
  35. package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
  36. package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
  37. package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
  38. package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
  39. package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
  40. package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
  41. package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
  42. package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
  43. package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
  44. package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
  45. package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
  46. package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
  47. package/tm/web/backend/srv/ent/access.ts.frag +179 -0
  48. package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
  49. package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
  50. package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
  51. package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
  52. package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
  53. package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
  54. package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
  55. package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
  56. package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
  57. package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
  58. package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
  59. package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
  60. package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
  61. package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
  62. package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
  63. package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
  64. package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
  65. package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
  66. package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
  67. package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
  68. package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
  69. package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
  70. package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
  71. package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
  72. package/tm/web/docs/reference/web-app.md.frag +98 -0
  73. package/tm/web/web/AGENTS.md.frag +52 -0
  74. package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
  75. package/tm/web/web/index.html.frag +3 -0
  76. package/tm/web/web/package.json.frag +4 -1
  77. package/tm/web/web/playwright.config.js.frag +6 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +46 -23
  80. package/tm/web/web/src/cmp/admin.js.frag +292 -193
  81. package/tm/web/web/src/cmp/admin.md.frag +51 -0
  82. package/tm/web/web/src/cmp/app.js.frag +12 -15
  83. package/tm/web/web/src/cmp/app.md.frag +28 -0
  84. package/tm/web/web/src/cmp/auth.js.frag +69 -41
  85. package/tm/web/web/src/cmp/auth.md.frag +36 -0
  86. package/tm/web/web/src/cmp/public.js.frag +58 -0
  87. package/tm/web/web/src/cmp/public.md.frag +26 -0
  88. package/tm/web/web/src/cmp/settings.js.frag +178 -0
  89. package/tm/web/web/src/cmp/settings.md.frag +30 -0
  90. package/tm/web/web/src/cmp/shell.js.frag +231 -0
  91. package/tm/web/web/src/cmp/shell.md.frag +49 -0
  92. package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
  93. package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
  94. package/tm/web/web/src/custom.css.frag +8 -0
  95. package/tm/web/web/src/customise.js.frag +30 -0
  96. package/tm/web/web/src/hooks.js.frag +79 -0
  97. package/tm/web/web/src/main.js.frag +17 -4
  98. package/tm/web/web/src/model.js.frag +162 -0
  99. package/tm/web/web/src/style.css.frag +135 -21
  100. package/tm/web/web/src/theme.js.frag +82 -0
@@ -0,0 +1,315 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import Crypto from 'node:crypto'
5
+
6
+ import Seneca from 'seneca'
7
+
8
+ import Model from '../../../../model/model.json'
9
+ import { pickEntity } from '../../srv/api/api.setup'
10
+
11
+ // The router under test, plus a stub-friendly seneca. The API service and
12
+ // the entity service are stood in for with MOCK MESSAGES, so this covers
13
+ // the HTTP mapping alone: routing, key auth, status codes, result shaping.
14
+ const { apiHandler } = require('../../../../dist/env/web/api.js')
15
+
16
+
17
+ // The entity and the base path come from the model, so this suite holds
18
+ // for any entity graph.
19
+ const ENT = pickEntity()
20
+ const API = (Model as any).main.api || {}
21
+ const BASE = '/' + (API.version || 'v1') + '/' + (ENT ? ENT.canon : 'none/none')
22
+
23
+ const KEY = 'vk_' + 'a'.repeat(48)
24
+ const HASH = Crypto.createHash('sha256').update(KEY).digest('hex')
25
+
26
+
27
+ async function makeSeneca(opts: {
28
+ keys?: any[], // sys/apikey rows
29
+ users?: any[], // sys/user rows
30
+ onEnt?: (msg: any) => any,
31
+ } = {}) {
32
+ const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
33
+ seneca.context.model = Model
34
+ seneca.test().use('promisify').use('entity')
35
+
36
+ seneca.message('aim:api,on:ent', async function (msg: any) {
37
+ return opts.onEnt ? opts.onEnt(msg) : { ok: true, items: [], item: null }
38
+ })
39
+
40
+ await seneca.ready()
41
+
42
+ for (const k of opts.keys || []) {
43
+ await seneca.entity('sys/apikey').data$(k).save$()
44
+ }
45
+ for (const u of opts.users || []) {
46
+ await seneca.entity('sys/user').data$(u).save$()
47
+ }
48
+
49
+ return seneca
50
+ }
51
+
52
+
53
+ // Minimal express-alike req/res.
54
+ function reqres(method: string, path: string, opts: {
55
+ headers?: Record<string, string>, body?: any, query?: any,
56
+ } = {}) {
57
+ const req = {
58
+ method,
59
+ path,
60
+ headers: opts.headers || {},
61
+ body: opts.body,
62
+ query: opts.query || {},
63
+ }
64
+ const res: any = {
65
+ statusCode: 200,
66
+ body: undefined as any,
67
+ sentFile: undefined as any,
68
+ type() {
69
+ return res
70
+ },
71
+ status(code: number) {
72
+ res.statusCode = code
73
+ return res
74
+ },
75
+ send(payload: any) {
76
+ res.body = payload
77
+ return res
78
+ },
79
+ sendFile(p: string) {
80
+ res.sentFile = p
81
+ return res
82
+ },
83
+ }
84
+ return { req, res }
85
+ }
86
+
87
+
88
+ const authed = { authorization: 'Bearer ' + KEY }
89
+
90
+ function liveKey() {
91
+ return [{ id: 'k01', user_id: 'u01', name: 'ci', hash: HASH, revoked: false }]
92
+ }
93
+ function liveUser() {
94
+ return [{ id: 'u01', email: 'alice@ex.com', name: 'Alice', pass: 'SECRET', salt: 'S' }]
95
+ }
96
+
97
+
98
+ describe('rest api router', () => {
99
+
100
+ // Every routed case needs an exposed entity to address.
101
+ const needEnt = (t: any) => (ENT ? false : (t.skip('model exposes no entities'), true))
102
+
103
+
104
+ test('serves the generated openapi spec unauthenticated', async () => {
105
+ const seneca = await makeSeneca()
106
+ const handle = apiHandler(seneca, Model)
107
+
108
+ const { req, res } = reqres('GET', '/openapi.json')
109
+ await handle(req, res)
110
+ assert.ok(String(res.sentFile).endsWith('openapi.json'))
111
+ await seneca.close()
112
+ })
113
+
114
+
115
+ test('requires a valid, unrevoked key', async (t) => {
116
+ if (needEnt(t)) { return }
117
+ const seneca = await makeSeneca({ keys: liveKey(), users: liveUser() })
118
+ const handle = apiHandler(seneca, Model)
119
+
120
+ // No header.
121
+ let rr = reqres('GET', BASE)
122
+ await handle(rr.req, rr.res)
123
+ assert.strictEqual(rr.res.statusCode, 401)
124
+ assert.strictEqual(rr.res.body.error.code, 'not-authenticated')
125
+
126
+ // Malformed header.
127
+ rr = reqres('GET', BASE, { headers: { authorization: 'Basic xyz' } })
128
+ await handle(rr.req, rr.res)
129
+ assert.strictEqual(rr.res.statusCode, 401)
130
+
131
+ // Unknown key.
132
+ rr = reqres('GET', BASE,
133
+ { headers: { authorization: 'Bearer vk_nope' } })
134
+ await handle(rr.req, rr.res)
135
+ assert.strictEqual(rr.res.statusCode, 401)
136
+
137
+ // Valid key.
138
+ rr = reqres('GET', BASE, { headers: authed })
139
+ await handle(rr.req, rr.res)
140
+ assert.strictEqual(rr.res.statusCode, 200)
141
+
142
+ await seneca.close()
143
+ })
144
+
145
+
146
+ test('a revoked key fails immediately', async (t) => {
147
+ if (needEnt(t)) { return }
148
+ const seneca = await makeSeneca({
149
+ keys: [{ id: 'k01', user_id: 'u01', hash: HASH, revoked: true }],
150
+ users: liveUser(),
151
+ })
152
+ const handle = apiHandler(seneca, Model)
153
+
154
+ const { req, res } = reqres('GET', BASE, { headers: authed })
155
+ await handle(req, res)
156
+ assert.strictEqual(res.statusCode, 401)
157
+ await seneca.close()
158
+ })
159
+
160
+
161
+ test('a key whose user is gone fails', async (t) => {
162
+ if (needEnt(t)) { return }
163
+ const seneca = await makeSeneca({ keys: liveKey() })
164
+ const handle = apiHandler(seneca, Model)
165
+
166
+ const { req, res } = reqres('GET', BASE, { headers: authed })
167
+ await handle(req, res)
168
+ assert.strictEqual(res.statusCode, 401)
169
+ await seneca.close()
170
+ })
171
+
172
+
173
+ test('maps methods and paths onto api operations', async (t) => {
174
+ if (needEnt(t)) { return }
175
+ const seen: any[] = []
176
+ const seneca = await makeSeneca({
177
+ keys: liveKey(), users: liveUser(),
178
+ onEnt: (msg: any) => {
179
+ seen.push({ op: msg.op, ent: msg.ent, id: msg.id, data: msg.data, q: msg.q })
180
+ return 'remove' === msg.op ? { ok: true, id: msg.id }
181
+ : 'list' === msg.op ? { ok: true, items: [{ id: 'p01' }] }
182
+ : { ok: true, item: { id: msg.id || 'p01' } }
183
+ },
184
+ })
185
+ const handle = apiHandler(seneca, Model)
186
+
187
+ let rr = reqres('GET', BASE, { headers: authed, query: { name: 'x' } })
188
+ await handle(rr.req, rr.res)
189
+ assert.strictEqual(rr.res.statusCode, 200)
190
+ assert.deepStrictEqual(rr.res.body.items, [{ id: 'p01' }])
191
+ assert.deepStrictEqual(seen[0], {
192
+ op: 'list', ent: ENT!.canon, id: undefined, data: undefined, q: { name: 'x' },
193
+ })
194
+
195
+ rr = reqres('GET', BASE + '/p01', { headers: authed })
196
+ await handle(rr.req, rr.res)
197
+ assert.strictEqual(seen[1].op, 'load')
198
+ assert.strictEqual(seen[1].id, 'p01')
199
+
200
+ rr = reqres('POST', BASE, { headers: authed, body: { name: 'New' } })
201
+ await handle(rr.req, rr.res)
202
+ // Creation answers 201.
203
+ assert.strictEqual(rr.res.statusCode, 201)
204
+ assert.strictEqual(seen[2].op, 'create')
205
+ assert.deepStrictEqual(seen[2].data, { name: 'New' })
206
+
207
+ rr = reqres('PUT', BASE + '/p01', { headers: authed, body: { note: 'n' } })
208
+ await handle(rr.req, rr.res)
209
+ assert.strictEqual(seen[3].op, 'update')
210
+
211
+ rr = reqres('DELETE', BASE + '/p01', { headers: authed })
212
+ await handle(rr.req, rr.res)
213
+ assert.strictEqual(seen[4].op, 'remove')
214
+ assert.deepStrictEqual(rr.res.body, { ok: true, id: 'p01' })
215
+
216
+ await seneca.close()
217
+ })
218
+
219
+
220
+ test('rejects unroutable paths and method/path combinations', async (t) => {
221
+ if (needEnt(t)) { return }
222
+ const seneca = await makeSeneca({ keys: liveKey(), users: liveUser() })
223
+ const handle = apiHandler(seneca, Model)
224
+
225
+ // Wrong version, too short, too long.
226
+ for (const p of ['/v9/a/b', '/v1/a', '/v1/a/b/c/d']) {
227
+ const rr = reqres('GET', p, { headers: authed })
228
+ await handle(rr.req, rr.res)
229
+ assert.strictEqual(rr.res.statusCode, 404, p)
230
+ }
231
+
232
+ // POST to an id path, PUT/DELETE to a collection path.
233
+ for (const [m, p] of [['POST', BASE + '/p01'],
234
+ ['PUT', BASE], ['DELETE', BASE]]) {
235
+ const rr = reqres(m, p, { headers: authed })
236
+ await handle(rr.req, rr.res)
237
+ assert.strictEqual(rr.res.statusCode, 405, m + ' ' + p)
238
+ }
239
+
240
+ await seneca.close()
241
+ })
242
+
243
+
244
+ test('maps service failures onto http status codes', async (t) => {
245
+ if (needEnt(t)) { return }
246
+ const cases: [string, number][] = [
247
+ ['unknown-entity', 404],
248
+ ['not-found', 404],
249
+ ['forbidden', 403],
250
+ ['invalid-data', 400],
251
+ ['project-required', 400],
252
+ ['read-only', 405],
253
+ ['not-authenticated', 401],
254
+ ['something-else', 500],
255
+ ]
256
+
257
+ for (const [why, status] of cases) {
258
+ const seneca = await makeSeneca({
259
+ keys: liveKey(), users: liveUser(),
260
+ onEnt: () => ({ ok: false, why, message: 'nope', details: [{ path: 'x' }] }),
261
+ })
262
+ const handle = apiHandler(seneca, Model)
263
+
264
+ const { req, res } = reqres('GET', BASE, { headers: authed })
265
+ await handle(req, res)
266
+ assert.strictEqual(res.statusCode, status, why)
267
+ assert.strictEqual(res.body.error.code, why)
268
+ assert.strictEqual(res.body.error.message, 'nope')
269
+ assert.ok(res.body.error.details)
270
+ await seneca.close()
271
+ }
272
+ })
273
+
274
+
275
+ test('an unexpected error becomes a 500', async (t) => {
276
+ if (needEnt(t)) { return }
277
+ const seneca = await makeSeneca({
278
+ keys: liveKey(), users: liveUser(),
279
+ onEnt: () => {
280
+ throw new Error('boom')
281
+ },
282
+ })
283
+ const handle = apiHandler(seneca, Model)
284
+
285
+ const { req, res } = reqres('GET', BASE, { headers: authed })
286
+ await handle(req, res)
287
+ assert.strictEqual(res.statusCode, 500)
288
+ assert.strictEqual(res.body.error.code, 'error')
289
+ await seneca.close()
290
+ })
291
+
292
+
293
+ test('the principal never carries credentials', async (t) => {
294
+ if (needEnt(t)) { return }
295
+ let principal: any = null
296
+ const seneca = await makeSeneca({
297
+ keys: liveKey(), users: liveUser(),
298
+ onEnt: (msg: any) => {
299
+ principal = msg.custom$.principal.user
300
+ return { ok: true, items: [] }
301
+ },
302
+ })
303
+ const handle = apiHandler(seneca, Model)
304
+
305
+ const { req, res } = reqres('GET', BASE, { headers: authed })
306
+ await handle(req, res)
307
+
308
+ assert.strictEqual(principal.id, 'u01')
309
+ assert.strictEqual(principal.email, 'alice@ex.com')
310
+ assert.strictEqual(principal.pass, undefined)
311
+ assert.strictEqual(principal.salt, undefined)
312
+ await seneca.close()
313
+ })
314
+ })
315
+
@@ -0,0 +1,109 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import Model from '../../../../model/model.json'
5
+
6
+
7
+ // THE BROWSER SURFACE.
8
+ //
9
+ // A browser may only send aim:web messages: the gateway allow-list in
10
+ // src/env/web/web.ts names that one namespace, and every message the SPA
11
+ // sends is declared in the model as an aim:web proxy that forwards to the
12
+ // real service message. These tests pin that contract from the model
13
+ // itself, so widening the surface cannot pass unnoticed.
14
+
15
+ const MSG: any = (Model as any).main.msg.aim
16
+ const SRV: any = (Model as any).main.srv
17
+
18
+
19
+ // Every leaf message pattern under a subtree.
20
+ function leaves(node: any, path: string[] = []): string[][] {
21
+ const keys = Object.keys(node || {}).filter((k) => '$' !== k)
22
+ if (0 === keys.length) {
23
+ return [path]
24
+ }
25
+ const out: string[][] = []
26
+ for (const k of keys) {
27
+ if (node[k] && 'object' === typeof node[k]) {
28
+ out.push(...leaves(node[k], [...path, k]))
29
+ }
30
+ }
31
+ return out
32
+ }
33
+
34
+
35
+ describe('browser surface', () => {
36
+
37
+ test('the gateway allows aim:web and nothing else', () => {
38
+ const src = require('node:fs').readFileSync(
39
+ __dirname + '/../../../../src/env/web/web.ts', 'utf8')
40
+
41
+ // The allow-list is a literal so it can be read off the source and
42
+ // reviewed: no computed expansion over the service list.
43
+ const m = src.match(/allow:\s*\{([^}]*)\}/)
44
+ assert.ok(m, 'gateway allow-list not found')
45
+ const keys = m[1].match(/'([^']+)'/g) || []
46
+ assert.deepStrictEqual(keys, ["'aim:web'"])
47
+ })
48
+
49
+
50
+ test('every browser message is a declared aim:web proxy', () => {
51
+ const web = MSG.web
52
+ assert.ok(web, 'no aim:web messages declared')
53
+
54
+ // Each proxy names the action file that forwards it: proxies are
55
+ // explicit, never implicit pass-through.
56
+ for (const path of leaves(web.on || {})) {
57
+ const node = path.reduce((n: any, k: string) => n[k], web.on)
58
+ const file = node && node.$ && node.$.file
59
+ assert.ok(file, 'aim:web,on:' + path.join(',') + ' has no proxy action file')
60
+ assert.ok(String(file).includes('web_'),
61
+ 'proxy action should be a web_ file: ' + file)
62
+ }
63
+ })
64
+
65
+
66
+ test('service namespaces are not part of the browser surface', () => {
67
+ // Every service in the model owns a message namespace of its own...
68
+ const services = Object.keys(SRV)
69
+ assert.ok(0 < services.length)
70
+ for (const name of services) {
71
+ assert.ok(MSG[name], 'expected service messages for aim:' + name)
72
+ // ...which is NOT the browser namespace.
73
+ assert.notStrictEqual(name, 'web')
74
+ }
75
+
76
+ // ...and no service declares that it accepts the aim:web namespace
77
+ // wholesale: each names only the proxy subtree it owns.
78
+ for (const name of Object.keys(SRV)) {
79
+ const inweb = SRV[name].in && SRV[name].in.aim && SRV[name].in.aim.web
80
+ if (null == inweb) {
81
+ continue
82
+ }
83
+ assert.deepStrictEqual(Object.keys(inweb), ['on'],
84
+ 'srv ' + name + ' should only claim aim:web,on:<subtree>')
85
+ const owned = Object.keys(inweb.on)
86
+ assert.ok(0 < owned.length)
87
+ }
88
+ })
89
+
90
+
91
+ test('the entity service is reachable only through its proxies', () => {
92
+ const ent = MSG.web.on.ent
93
+ assert.ok(ent, 'no aim:web,on:ent proxies')
94
+
95
+ // Every command the generic entity service answers has a proxy, so
96
+ // the SPA never needs to post aim:ent directly.
97
+ const commands = Object.keys(MSG.ent.cmd).sort()
98
+ assert.deepStrictEqual(Object.keys(ent.cmd).sort(), commands)
99
+ })
100
+
101
+
102
+ test('the REST API namespace is not browser-reachable', () => {
103
+ // aim:api is the API-key clients' proxy layer, reached through the
104
+ // REST router - it must not appear in the browser surface.
105
+ assert.strictEqual(MSG.web.on.api, undefined)
106
+ assert.ok(MSG.api, 'aim:api messages should still exist for the router')
107
+ })
108
+ })
109
+
@@ -0,0 +1,162 @@
1
+ import Seneca from 'seneca'
2
+
3
+ import Model from '../../../../model/model.json'
4
+
5
+ const Expose = require('../../../../dist/srv/api/expose.js')
6
+
7
+
8
+ // Seneca with the REST API service loaded from compiled dist/srv.
9
+ //
10
+ // The generic entity service is NOT loaded: it is stood in for with MOCK
11
+ // MESSAGES (see mockEnt below), so these tests exercise the API layer -
12
+ // exposure rules, generated-shape validation, op routing, result shaping -
13
+ // in isolation, with no store and no access control in the way.
14
+ async function makeSeneca(mock?: (seneca: any) => void) {
15
+ const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
16
+ seneca.context.model = Model
17
+ seneca.context.env = 'test'
18
+
19
+ seneca.test()
20
+
21
+ seneca
22
+ .use('promisify')
23
+ .use('reload')
24
+ .use('../../../../dist/srv/api/api-srv')
25
+
26
+ if (mock) {
27
+ mock(seneca)
28
+ }
29
+
30
+ return seneca.ready()
31
+ }
32
+
33
+
34
+ // Stand-in for the generic entity service: records the calls it receives
35
+ // and answers from a simple in-test store.
36
+ function mockEnt(store: Record<string, any[]> = {}) {
37
+ const calls: any[] = []
38
+
39
+ const install = (seneca: any) => {
40
+ seneca.message('aim:ent,cmd:list', async function (msg: any) {
41
+ calls.push({ cmd: 'list', ent: msg.ent, q: msg.q })
42
+ const list = (store[msg.ent] || []).filter((item: any) =>
43
+ Object.keys(msg.q || {}).every((k) => item[k] === msg.q[k]))
44
+ return { ok: true, list }
45
+ })
46
+
47
+ seneca.message('aim:ent,cmd:load', async function (msg: any) {
48
+ calls.push({ cmd: 'load', ent: msg.ent, id: msg.id })
49
+ const item = (store[msg.ent] || []).find((i: any) => i.id === msg.id)
50
+ return { ok: true, item: item || null }
51
+ })
52
+
53
+ seneca.message('aim:ent,cmd:save', async function (msg: any) {
54
+ calls.push({ cmd: 'save', ent: msg.ent, item: msg.item })
55
+ const item = Object.assign({ id: msg.item.id || 'new01' }, msg.item)
56
+ store[msg.ent] = (store[msg.ent] || []).filter((i: any) => i.id !== item.id)
57
+ store[msg.ent].push(item)
58
+ return { ok: true, item }
59
+ })
60
+
61
+ seneca.message('aim:ent,cmd:remove', async function (msg: any) {
62
+ calls.push({ cmd: 'remove', ent: msg.ent, id: msg.id })
63
+ store[msg.ent] = (store[msg.ent] || []).filter((i: any) => i.id !== msg.id)
64
+ return { ok: true, id: msg.id }
65
+ })
66
+ }
67
+
68
+ return { install, calls, store }
69
+ }
70
+
71
+
72
+ // Post an API message AS a given user (the router resolves the principal
73
+ // from the API key and passes it the same way).
74
+ function as(seneca: any, user: any, msg: any) {
75
+ return seneca.post(Object.assign({}, msg, {
76
+ custom$: { principal: { user } },
77
+ }))
78
+ }
79
+
80
+
81
+ // ---- model-driven test subject -----------------------------------------
82
+ //
83
+ // These tests must hold for ANY model, so the entity under test and the
84
+ // payloads are derived from the model rather than hard-coded.
85
+
86
+ // Fields the server owns: never client-writable.
87
+ const MANAGED = ['id', 'owner_id', 't_c', 't_m']
88
+
89
+ const SAMPLE: Record<string, any> = {
90
+ String: 'test-value',
91
+ Number: 42,
92
+ Boolean: true,
93
+ }
94
+
95
+ // The wrong type to send for a given kind (to trip validation).
96
+ const WRONG: Record<string, any> = {
97
+ String: 123,
98
+ Number: 'not-a-number',
99
+ Boolean: 'not-a-boolean',
100
+ }
101
+
102
+
103
+ // Pick an entity the API exposes, with everything a test needs to build
104
+ // valid and invalid payloads for it. Returns null for a model with no
105
+ // exposed entities (a brand new project).
106
+ function pickEntity() {
107
+ const canons: string[] = Expose.exposedCanons(Model)
108
+ if (0 === canons.length) {
109
+ return null
110
+ }
111
+
112
+ const canon = canons[0]
113
+ const [zone, name] = canon.split('/')
114
+ const fields = ((Model as any).main.ent[zone][name] || {}).field || {}
115
+
116
+ const writable = Object.keys(fields).filter((f) => !MANAGED.includes(f))
117
+ const required = writable.filter((f) => {
118
+ const valid = null == fields[f].valid ? '' : String(fields[f].valid)
119
+ return !/Skip/.test(valid)
120
+ })
121
+
122
+ const kindOf = (f: string) =>
123
+ ['String', 'Number', 'Boolean'].includes(fields[f].kind) ? fields[f].kind : 'String'
124
+
125
+ // A minimal valid payload: every required field, nothing else.
126
+ const valid = () => required.reduce((a: any, f: string) =>
127
+ (a[f] = SAMPLE[kindOf(f)], a), {} as any)
128
+
129
+ // A payload with one field of the wrong type, when the entity has a
130
+ // field whose kind can be violated unambiguously.
131
+ const typed = writable.find((f) => 'Number' === kindOf(f) || 'Boolean' === kindOf(f))
132
+ const wrongType = () => {
133
+ if (null == typed) {
134
+ return null
135
+ }
136
+ return Object.assign(valid(), { [typed]: WRONG[kindOf(typed)] })
137
+ }
138
+
139
+ // A VALID value for a field that differs from what valid() sets, so a
140
+ // partial update is observable.
141
+ const patchValue = (f: string) => {
142
+ const kind = kindOf(f)
143
+ return 'String' === kind ? 'changed-value'
144
+ : 'Number' === kind ? 99
145
+ : false
146
+ }
147
+
148
+ return {
149
+ canon, zone, name, fields, writable, required,
150
+ valid, wrongType, kindOf, patchValue,
151
+ }
152
+ }
153
+
154
+
155
+ export {
156
+ makeSeneca,
157
+ mockEnt,
158
+ as,
159
+ pickEntity,
160
+ Model,
161
+ }
162
+