@voxgig/build 4.4.0 → 4.12.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 +197 -11
  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 +219 -13
  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 +43 -8
  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 +1 -0
  76. package/tm/web/web/package.json.frag +1 -0
  77. package/tm/web/web/playwright.config.js.frag +2 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +14 -8
  80. package/tm/web/web/src/cmp/admin.js.frag +293 -212
  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,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
+
@@ -0,0 +1,225 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import { makeSeneca, mockEnt, as, pickEntity } from './api.setup'
5
+
6
+
7
+ const ALICE = { id: 'u01', email: 'alice@ex.com' }
8
+
9
+ // The entity under test comes from the model, so this suite holds for any
10
+ // entity graph (see pickEntity).
11
+ const ENT = pickEntity()
12
+
13
+
14
+ describe('api service', () => {
15
+
16
+ test('get-info', async () => {
17
+ const seneca = await makeSeneca()
18
+ const out = await seneca.post('aim:api,get:info')
19
+ assert.strictEqual(out.ok, true)
20
+ assert.strictEqual(out.srv, 'api')
21
+ await seneca.close()
22
+ })
23
+
24
+
25
+ test('unknown entities, the sys zone and unknown ops are rejected', async () => {
26
+ const ent = mockEnt()
27
+ const seneca = await makeSeneca(ent.install)
28
+
29
+ for (const canon of ['nope/nope', 'sys/user', 'sys/apikey']) {
30
+ const out = await as(seneca, ALICE,
31
+ { aim: 'api', on: 'ent', op: 'list', ent: canon })
32
+ assert.strictEqual(out.ok, false, canon)
33
+ assert.strictEqual(out.why, 'unknown-entity', canon)
34
+ }
35
+
36
+ if (ENT) {
37
+ const badop = await as(seneca, ALICE,
38
+ { aim: 'api', on: 'ent', op: 'wat', ent: ENT.canon })
39
+ assert.strictEqual(badop.ok, false)
40
+ assert.strictEqual(badop.why, 'unknown-op')
41
+ }
42
+
43
+ // Nothing reached the entity service.
44
+ assert.strictEqual(ent.calls.length, 0)
45
+ await seneca.close()
46
+ })
47
+
48
+
49
+ test('list delegates to the entity service, query included', async (t) => {
50
+ if (!ENT) {
51
+ return t.skip('model exposes no entities')
52
+ }
53
+ const field = ENT.required[0] || ENT.writable[0]
54
+ const rows = [
55
+ Object.assign({ id: 'e01' }, ENT.valid()),
56
+ Object.assign({ id: 'e02' }, ENT.valid(), field ? { [field]: 'other' } : {}),
57
+ ]
58
+ const ent = mockEnt({ [ENT.canon]: rows })
59
+ const seneca = await makeSeneca(ent.install)
60
+
61
+ const all = await as(seneca, ALICE,
62
+ { aim: 'api', on: 'ent', op: 'list', ent: ENT.canon })
63
+ assert.strictEqual(all.ok, true)
64
+ assert.strictEqual(all.items.length, 2)
65
+ assert.deepStrictEqual(ent.calls[0], { cmd: 'list', ent: ENT.canon, q: {} })
66
+
67
+ if (field) {
68
+ const some = await as(seneca, ALICE, {
69
+ aim: 'api', on: 'ent', op: 'list', ent: ENT.canon,
70
+ q: { [field]: 'other' },
71
+ })
72
+ assert.strictEqual(some.items.length, 1)
73
+ assert.strictEqual(some.items[0].id, 'e02')
74
+ }
75
+
76
+ await seneca.close()
77
+ })
78
+
79
+
80
+ test('load returns the entity, or not-found', async (t) => {
81
+ if (!ENT) {
82
+ return t.skip('model exposes no entities')
83
+ }
84
+ const ent = mockEnt({ [ENT.canon]: [Object.assign({ id: 'e01' }, ENT.valid())] })
85
+ const seneca = await makeSeneca(ent.install)
86
+
87
+ const got = await as(seneca, ALICE,
88
+ { aim: 'api', on: 'ent', op: 'load', ent: ENT.canon, id: 'e01' })
89
+ assert.strictEqual(got.ok, true)
90
+ assert.strictEqual(got.item.id, 'e01')
91
+
92
+ const missing = await as(seneca, ALICE,
93
+ { aim: 'api', on: 'ent', op: 'load', ent: ENT.canon, id: 'nope' })
94
+ assert.strictEqual(missing.ok, false)
95
+ assert.strictEqual(missing.why, 'not-found')
96
+
97
+ await seneca.close()
98
+ })
99
+
100
+
101
+ test('create validates against the generated shape', async (t) => {
102
+ if (!ENT) {
103
+ return t.skip('model exposes no entities')
104
+ }
105
+ const ent = mockEnt()
106
+ const seneca = await makeSeneca(ent.install)
107
+
108
+ const made = await as(seneca, ALICE, {
109
+ aim: 'api', on: 'ent', op: 'create', ent: ENT.canon, data: ENT.valid(),
110
+ })
111
+ assert.strictEqual(made.ok, true)
112
+ assert.ok(made.item.id)
113
+
114
+ // The shapes are closed: unknown properties are rejected (strict JSON).
115
+ const extra = await as(seneca, ALICE, {
116
+ aim: 'api', on: 'ent', op: 'create', ent: ENT.canon,
117
+ data: Object.assign(ENT.valid(), { definitely_not_a_field: 1 }),
118
+ })
119
+ assert.strictEqual(extra.ok, false)
120
+ assert.strictEqual(extra.why, 'invalid-data')
121
+ assert.ok(extra.details.some((d: any) => 'closed' === d.what))
122
+
123
+ // Server-managed fields are not client-writable either.
124
+ const managed = await as(seneca, ALICE, {
125
+ aim: 'api', on: 'ent', op: 'create', ent: ENT.canon,
126
+ data: Object.assign(ENT.valid(), { owner_id: 'someone-else' }),
127
+ })
128
+ assert.strictEqual(managed.ok, false)
129
+ assert.strictEqual(managed.why, 'invalid-data')
130
+
131
+ // A required field left out.
132
+ if (0 < ENT.required.length) {
133
+ const bare = await as(seneca, ALICE,
134
+ { aim: 'api', on: 'ent', op: 'create', ent: ENT.canon, data: {} })
135
+ assert.strictEqual(bare.ok, false)
136
+ assert.strictEqual(bare.why, 'invalid-data')
137
+ assert.ok(bare.details.some((d: any) => 'required' === d.what))
138
+ }
139
+
140
+ // A field of the wrong type.
141
+ const wrong = ENT.wrongType()
142
+ if (wrong) {
143
+ const out = await as(seneca, ALICE,
144
+ { aim: 'api', on: 'ent', op: 'create', ent: ENT.canon, data: wrong })
145
+ assert.strictEqual(out.ok, false)
146
+ assert.strictEqual(out.why, 'invalid-data')
147
+ assert.ok(out.details.some((d: any) => 'type' === d.what))
148
+ }
149
+
150
+ await seneca.close()
151
+ })
152
+
153
+
154
+ test('update merges onto the existing entity', async (t) => {
155
+ if (!ENT) {
156
+ return t.skip('model exposes no entities')
157
+ }
158
+ const field = ENT.writable[0]
159
+ if (null == field) {
160
+ return t.skip('entity has no writable fields')
161
+ }
162
+
163
+ const existing = Object.assign({ id: 'e01' }, ENT.valid())
164
+ const ent = mockEnt({ [ENT.canon]: [existing] })
165
+ const seneca = await makeSeneca(ent.install)
166
+
167
+ const patch = { [field]: ENT.patchValue(field) }
168
+ const out = await as(seneca, ALICE, {
169
+ aim: 'api', on: 'ent', op: 'update', ent: ENT.canon, id: 'e01', data: patch,
170
+ })
171
+ assert.strictEqual(out.ok, true)
172
+ assert.strictEqual(out.item.id, 'e01')
173
+ assert.strictEqual(out.item[field], patch[field])
174
+
175
+ // Fields not named in a partial update survive it.
176
+ for (const other of ENT.required.filter((f: string) => f !== field)) {
177
+ assert.strictEqual(out.item[other], existing[other], other)
178
+ }
179
+
180
+ const missing = await as(seneca, ALICE, {
181
+ aim: 'api', on: 'ent', op: 'update', ent: ENT.canon, id: 'nope', data: patch,
182
+ })
183
+ assert.strictEqual(missing.ok, false)
184
+ assert.strictEqual(missing.why, 'not-found')
185
+
186
+ await seneca.close()
187
+ })
188
+
189
+
190
+ test('remove deletes an existing entity only', async (t) => {
191
+ if (!ENT) {
192
+ return t.skip('model exposes no entities')
193
+ }
194
+ const ent = mockEnt({ [ENT.canon]: [Object.assign({ id: 'e01' }, ENT.valid())] })
195
+ const seneca = await makeSeneca(ent.install)
196
+
197
+ const gone = await as(seneca, ALICE,
198
+ { aim: 'api', on: 'ent', op: 'remove', ent: ENT.canon, id: 'e01' })
199
+ assert.strictEqual(gone.ok, true)
200
+
201
+ const again = await as(seneca, ALICE,
202
+ { aim: 'api', on: 'ent', op: 'remove', ent: ENT.canon, id: 'e01' })
203
+ assert.strictEqual(again.ok, false)
204
+ assert.strictEqual(again.why, 'not-found')
205
+
206
+ await seneca.close()
207
+ })
208
+
209
+
210
+ test('a failure from the entity service is passed through', async (t) => {
211
+ if (!ENT) {
212
+ return t.skip('model exposes no entities')
213
+ }
214
+ const seneca = await makeSeneca((s: any) => {
215
+ s.message('aim:ent,cmd:list', async () => ({ ok: false, why: 'forbidden' }))
216
+ })
217
+
218
+ const out = await as(seneca, ALICE,
219
+ { aim: 'api', on: 'ent', op: 'list', ent: ENT.canon })
220
+ assert.strictEqual(out.ok, false)
221
+ assert.strictEqual(out.why, 'forbidden')
222
+ await seneca.close()
223
+ })
224
+ })
225
+
@@ -0,0 +1,152 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import Crypto from 'node:crypto'
5
+
6
+ import { makeSeneca, as } from './auth.setup'
7
+
8
+
9
+ // API access keys: the raw key is shown once, only its hash is stored,
10
+ // revocation is immediate, and the gateway wrappers act on the SIGNED-IN
11
+ // user rather than a caller-supplied id.
12
+ describe('api keys', () => {
13
+
14
+ test('create returns the key once and stores only its hash', async () => {
15
+ const seneca = await makeSeneca()
16
+
17
+ const made = await seneca.post('aim:auth,create:apikey',
18
+ { user_id: 'u01', name: 'ci' })
19
+ assert.strictEqual(made.ok, true)
20
+ assert.ok(made.key.startsWith('vk_'))
21
+ assert.strictEqual(made.item.name, 'ci')
22
+ assert.strictEqual(made.item.revoked, false)
23
+ // The public view never carries the hash.
24
+ assert.strictEqual((made.item as any).hash, undefined)
25
+ assert.strictEqual(made.item.prefix, made.key.slice(0, 8))
26
+
27
+ // What is stored is the sha-256 of the key, not the key.
28
+ const stored = await seneca.entity('sys/apikey').load$(made.item.id)
29
+ const hash = Crypto.createHash('sha256').update(made.key).digest('hex')
30
+ assert.strictEqual(stored.hash, hash)
31
+ assert.ok(!JSON.stringify(stored.data$(false)).includes(made.key))
32
+
33
+ await seneca.close()
34
+ })
35
+
36
+
37
+ test('create rejects missing arguments', async () => {
38
+ const seneca = await makeSeneca()
39
+
40
+ const nouser = await seneca.post('aim:auth,create:apikey', { name: 'x' })
41
+ assert.strictEqual(nouser.ok, false)
42
+ assert.strictEqual(nouser.why, 'invalid-args')
43
+
44
+ const noname = await seneca.post('aim:auth,create:apikey',
45
+ { user_id: 'u01', name: ' ' })
46
+ assert.strictEqual(noname.ok, false)
47
+ assert.strictEqual(noname.why, 'invalid-args')
48
+
49
+ await seneca.close()
50
+ })
51
+
52
+
53
+ test('list returns only that user keys, without hashes', async () => {
54
+ const seneca = await makeSeneca()
55
+
56
+ await seneca.post('aim:auth,create:apikey', { user_id: 'u01', name: 'a' })
57
+ await seneca.post('aim:auth,create:apikey', { user_id: 'u01', name: 'b' })
58
+ await seneca.post('aim:auth,create:apikey', { user_id: 'u02', name: 'other' })
59
+
60
+ const mine = await seneca.post('aim:auth,list:apikey', { user_id: 'u01' })
61
+ assert.strictEqual(mine.ok, true)
62
+ assert.strictEqual(mine.items.length, 2)
63
+ assert.deepStrictEqual(mine.items.map((k: any) => k.name).sort(), ['a', 'b'])
64
+ assert.ok(mine.items.every((k: any) => undefined === k.hash))
65
+
66
+ const bad = await seneca.post('aim:auth,list:apikey', {})
67
+ assert.strictEqual(bad.ok, false)
68
+ assert.strictEqual(bad.why, 'invalid-args')
69
+
70
+ await seneca.close()
71
+ })
72
+
73
+
74
+ test('revoke marks the key revoked, and only for its owner', async () => {
75
+ const seneca = await makeSeneca()
76
+
77
+ const made = await seneca.post('aim:auth,create:apikey',
78
+ { user_id: 'u01', name: 'ci' })
79
+
80
+ // Another user cannot revoke it.
81
+ const notmine = await seneca.post('aim:auth,revoke:apikey',
82
+ { user_id: 'u02', id: made.item.id })
83
+ assert.strictEqual(notmine.ok, false)
84
+ assert.strictEqual(notmine.why, 'not-found')
85
+
86
+ const gone = await seneca.post('aim:auth,revoke:apikey',
87
+ { user_id: 'u01', id: made.item.id })
88
+ assert.strictEqual(gone.ok, true)
89
+ assert.strictEqual(gone.item.revoked, true)
90
+
91
+ // The record is kept (audit trail), flagged revoked.
92
+ const list = await seneca.post('aim:auth,list:apikey', { user_id: 'u01' })
93
+ assert.strictEqual(list.items.length, 1)
94
+ assert.strictEqual(list.items[0].revoked, true)
95
+
96
+ const missing = await seneca.post('aim:auth,revoke:apikey',
97
+ { user_id: 'u01', id: 'nope' })
98
+ assert.strictEqual(missing.ok, false)
99
+ assert.strictEqual(missing.why, 'not-found')
100
+
101
+ const bad = await seneca.post('aim:auth,revoke:apikey', { user_id: 'u01' })
102
+ assert.strictEqual(bad.ok, false)
103
+ assert.strictEqual(bad.why, 'invalid-args')
104
+
105
+ await seneca.close()
106
+ })
107
+
108
+
109
+ test('the gateway wrappers use the signed-in user', async () => {
110
+ const seneca = await makeSeneca()
111
+
112
+ // Unauthenticated calls are refused.
113
+ for (const msg of [
114
+ { aim: 'web', on: 'auth', create: 'apikey', name: 'x' },
115
+ { aim: 'web', on: 'auth', list: 'apikey' },
116
+ { aim: 'web', on: 'auth', revoke: 'apikey', id: 'x' },
117
+ ]) {
118
+ const out = await seneca.post(msg)
119
+ assert.strictEqual(out.ok, false)
120
+ assert.strictEqual(out.why, 'not-authenticated')
121
+ }
122
+
123
+ const made = await as(seneca, { id: 'u01' },
124
+ { aim: 'web', on: 'auth', create: 'apikey', name: 'from-web' })
125
+ assert.strictEqual(made.ok, true)
126
+
127
+ // The key belongs to the signed-in user, not anyone named in the msg.
128
+ const stored = await seneca.entity('sys/apikey').load$(made.item.id)
129
+ assert.strictEqual(stored.user_id, 'u01')
130
+
131
+ const mine = await as(seneca, { id: 'u01' },
132
+ { aim: 'web', on: 'auth', list: 'apikey' })
133
+ assert.strictEqual(mine.items.length, 1)
134
+
135
+ // Another user sees none of them, and cannot revoke.
136
+ const others = await as(seneca, { id: 'u02' },
137
+ { aim: 'web', on: 'auth', list: 'apikey' })
138
+ assert.strictEqual(others.items.length, 0)
139
+
140
+ const denied = await as(seneca, { id: 'u02' },
141
+ { aim: 'web', on: 'auth', revoke: 'apikey', id: made.item.id })
142
+ assert.strictEqual(denied.ok, false)
143
+
144
+ const revoked = await as(seneca, { id: 'u01' },
145
+ { aim: 'web', on: 'auth', revoke: 'apikey', id: made.item.id })
146
+ assert.strictEqual(revoked.ok, true)
147
+ assert.strictEqual(revoked.item.revoked, true)
148
+
149
+ await seneca.close()
150
+ })
151
+ })
152
+
@@ -0,0 +1,40 @@
1
+
2
+ import Seneca from 'seneca'
3
+
4
+ import Model from '../../../../model/model.json'
5
+
6
+ // The shared plugin setup (loads @seneca/user etc.) from compiled output.
7
+ const { basic } = require('../../../../dist/env/shared/basic.js')
8
+
9
+
10
+ // Seneca with @seneca/user + the auth service (from compiled dist/srv).
11
+ async function makeSeneca() {
12
+ const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
13
+ seneca.context.model = Model
14
+ seneca.context.env = 'test'
15
+
16
+ seneca.test()
17
+ basic(seneca)
18
+
19
+ seneca
20
+ .use('reload')
21
+ .use('../../../../dist/srv/auth/auth-srv')
22
+
23
+ return seneca.ready()
24
+ }
25
+
26
+
27
+ // Post a gateway message AS a signed-in user (principal in meta.custom).
28
+ function as(seneca: any, user: any, msg: any) {
29
+ return seneca.post(Object.assign({}, msg, {
30
+ custom$: { principal: { user } },
31
+ }))
32
+ }
33
+
34
+
35
+ export {
36
+ makeSeneca,
37
+ as,
38
+ Model,
39
+ }
40
+