@voxgig/build 4.4.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.
- package/README.md +51 -2
- package/build.ts +20 -0
- package/dist/api/api_gen.d.ts +7 -0
- package/dist/api/api_gen.js +370 -0
- package/dist/api/api_gen.js.map +1 -0
- package/dist/build.d.ts +16 -1
- package/dist/build.js +17 -1
- package/dist/build.js.map +1 -1
- package/dist/doc/doc_gen.d.ts +8 -0
- package/dist/doc/doc_gen.js +397 -0
- package/dist/doc/doc_gen.js.map +1 -0
- package/dist/env/web/web_gen.js +159 -1
- package/dist/env/web/web_gen.js.map +1 -1
- package/dist/shape/ent.js +4 -2
- package/dist/shape/ent.js.map +1 -1
- package/env/web/web_gen.ts +174 -1
- package/package.json +6 -9
- package/tm/web/backend/env/shared/seed.ts.frag +23 -0
- package/tm/web/backend/env/web/api.ts.frag +157 -0
- package/tm/web/backend/env/web/web.ts.frag +37 -4
- package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
- package/tm/web/backend/srv/api/expose.ts.frag +33 -0
- package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
- package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
- package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
- package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
- package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
- package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
- package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
- package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
- package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
- package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
- package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
- package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
- package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
- package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
- package/tm/web/backend/srv/ent/access.ts.frag +179 -0
- package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
- package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
- package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
- package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
- package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
- package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
- package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
- package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
- package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
- package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
- package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
- package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
- package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
- package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
- package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
- package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
- package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
- package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
- package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
- package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
- package/tm/web/docs/reference/web-app.md.frag +98 -0
- package/tm/web/web/AGENTS.md.frag +52 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
- package/tm/web/web/index.html.frag +1 -0
- package/tm/web/web/package.json.frag +1 -0
- package/tm/web/web/playwright.config.js.frag +2 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +14 -8
- package/tm/web/web/src/cmp/admin.js.frag +293 -212
- package/tm/web/web/src/cmp/admin.md.frag +51 -0
- package/tm/web/web/src/cmp/app.js.frag +12 -15
- package/tm/web/web/src/cmp/app.md.frag +28 -0
- package/tm/web/web/src/cmp/auth.js.frag +69 -41
- package/tm/web/web/src/cmp/auth.md.frag +36 -0
- package/tm/web/web/src/cmp/public.js.frag +58 -0
- package/tm/web/web/src/cmp/public.md.frag +26 -0
- package/tm/web/web/src/cmp/settings.js.frag +178 -0
- package/tm/web/web/src/cmp/settings.md.frag +30 -0
- package/tm/web/web/src/cmp/shell.js.frag +231 -0
- package/tm/web/web/src/cmp/shell.md.frag +49 -0
- package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
- package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
- package/tm/web/web/src/custom.css.frag +8 -0
- package/tm/web/web/src/customise.js.frag +30 -0
- package/tm/web/web/src/hooks.js.frag +79 -0
- package/tm/web/web/src/main.js.frag +17 -4
- package/tm/web/web/src/model.js.frag +162 -0
- package/tm/web/web/src/style.css.frag +135 -21
- package/tm/web/web/src/theme.js.frag +82 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import { makeSeneca, as } from './auth.setup'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// Sign-in / sign-out / session load, and the gateway wrappers that turn
|
|
8
|
+
// them into cookie operations for the SPA.
|
|
9
|
+
describe('auth session', () => {
|
|
10
|
+
|
|
11
|
+
async function withUser() {
|
|
12
|
+
const seneca = await makeSeneca()
|
|
13
|
+
const reg = await seneca.post('sys:user,register:user',
|
|
14
|
+
{ name: 'Alice', email: 'alice@ex.com', password: 'alice-pass-01' })
|
|
15
|
+
assert.strictEqual(reg.ok, true)
|
|
16
|
+
return { seneca, user: reg.user }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
test('signin succeeds with the right password and fails otherwise', async () => {
|
|
21
|
+
const { seneca } = await withUser()
|
|
22
|
+
|
|
23
|
+
const ok = await seneca.post('aim:auth,signin:user',
|
|
24
|
+
{ email: 'alice@ex.com', password: 'alice-pass-01' })
|
|
25
|
+
assert.strictEqual(ok.ok, true)
|
|
26
|
+
assert.strictEqual(ok.user.email, 'alice@ex.com')
|
|
27
|
+
|
|
28
|
+
// aim:auth messages are gateway-reachable, so the result must never
|
|
29
|
+
// carry credentials: @seneca/user answers with the whole entity
|
|
30
|
+
// (password hash + salt) and its `fields` option does not stop it.
|
|
31
|
+
assert.strictEqual(ok.user.pass, undefined)
|
|
32
|
+
assert.strictEqual(ok.user.salt, undefined)
|
|
33
|
+
assert.deepStrictEqual(Object.keys(ok.user).sort(),
|
|
34
|
+
['email', 'handle', 'id', 'name'])
|
|
35
|
+
|
|
36
|
+
const bad = await seneca.post('aim:auth,signin:user',
|
|
37
|
+
{ email: 'alice@ex.com', password: 'wrong' })
|
|
38
|
+
assert.strictEqual(bad.ok, false)
|
|
39
|
+
|
|
40
|
+
await seneca.close()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
test('the gateway wrapper returns a cookie token on success', async () => {
|
|
45
|
+
const { seneca } = await withUser()
|
|
46
|
+
|
|
47
|
+
const out = await seneca.post(
|
|
48
|
+
{ aim: 'web', on: 'auth', signin: 'user', email: 'alice@ex.com', password: 'alice-pass-01' })
|
|
49
|
+
assert.strictEqual(out.ok, true)
|
|
50
|
+
// gateway-auth (express_cookie) sets the cookie from this field.
|
|
51
|
+
assert.ok(out.gateway$.auth.token)
|
|
52
|
+
assert.strictEqual(out.user.email, 'alice@ex.com')
|
|
53
|
+
assert.strictEqual(out.user.pass, undefined)
|
|
54
|
+
|
|
55
|
+
const bad = await seneca.post(
|
|
56
|
+
{ aim: 'web', on: 'auth', signin: 'user', email: 'alice@ex.com', password: 'wrong' })
|
|
57
|
+
assert.strictEqual(bad.ok, false)
|
|
58
|
+
assert.strictEqual(bad.gateway$, undefined)
|
|
59
|
+
assert.ok(bad.why)
|
|
60
|
+
|
|
61
|
+
await seneca.close()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
test('signout revokes the login server-side', async () => {
|
|
66
|
+
const { seneca, user } = await withUser()
|
|
67
|
+
|
|
68
|
+
const login = await seneca.post('aim:auth,signin:user',
|
|
69
|
+
{ email: 'alice@ex.com', password: 'alice-pass-01' })
|
|
70
|
+
const token = login.login.token
|
|
71
|
+
assert.ok(token)
|
|
72
|
+
|
|
73
|
+
const before = await seneca.entity('sys/login').list$({ token })
|
|
74
|
+
assert.strictEqual(before[0].active, true)
|
|
75
|
+
|
|
76
|
+
const out = await seneca.post('aim:auth,signout:user',
|
|
77
|
+
{ user_id: user.id, token })
|
|
78
|
+
assert.strictEqual(out.ok, true)
|
|
79
|
+
|
|
80
|
+
// The session must really be revoked: a token alone does not do it
|
|
81
|
+
// (@seneca/user answers no-user-query and leaves the row active), so
|
|
82
|
+
// the user id has to travel with it.
|
|
83
|
+
const after = await seneca.entity('sys/login').list$({ token })
|
|
84
|
+
assert.strictEqual(after[0].active, false)
|
|
85
|
+
|
|
86
|
+
await seneca.close()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
test('the signout wrapper revokes the session and clears the cookie', async () => {
|
|
91
|
+
const { seneca, user } = await withUser()
|
|
92
|
+
|
|
93
|
+
const login = await seneca.post('aim:auth,signin:user',
|
|
94
|
+
{ email: 'alice@ex.com', password: 'alice-pass-01' })
|
|
95
|
+
const token = login.login.token
|
|
96
|
+
|
|
97
|
+
// The gateway principal carries both the user and the login token.
|
|
98
|
+
const out = await seneca.post({
|
|
99
|
+
aim: 'web', on: 'auth', signout: 'user',
|
|
100
|
+
custom$: { principal: { user, token } },
|
|
101
|
+
})
|
|
102
|
+
assert.strictEqual(out.ok, true)
|
|
103
|
+
// Tells gateway-auth to clear the cookie.
|
|
104
|
+
assert.strictEqual(out.gateway$.auth.remove, true)
|
|
105
|
+
|
|
106
|
+
const after = await seneca.entity('sys/login').list$({ token })
|
|
107
|
+
assert.strictEqual(after[0].active, false)
|
|
108
|
+
|
|
109
|
+
// Signing out with no principal at all is still a clean no-op.
|
|
110
|
+
const anon = await seneca.post({ aim: 'web', on: 'auth', signout: 'user' })
|
|
111
|
+
assert.strictEqual(anon.ok, true)
|
|
112
|
+
|
|
113
|
+
await seneca.close()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
test('load:auth reports the signed-in user, or nobody', async () => {
|
|
118
|
+
const { seneca, user } = await withUser()
|
|
119
|
+
|
|
120
|
+
const anon = await seneca.post({ aim: 'web', on: 'auth', load: 'auth' })
|
|
121
|
+
assert.strictEqual(anon.ok, true)
|
|
122
|
+
assert.strictEqual(anon.user, undefined)
|
|
123
|
+
|
|
124
|
+
const signed = await as(seneca, user, { aim: 'web', on: 'auth', load: 'auth' })
|
|
125
|
+
assert.strictEqual(signed.ok, true)
|
|
126
|
+
assert.strictEqual(signed.user.email, 'alice@ex.com')
|
|
127
|
+
assert.strictEqual(signed.user.pass, undefined)
|
|
128
|
+
|
|
129
|
+
// The service message behind it must not leak credentials either.
|
|
130
|
+
const direct = await seneca.post('aim:auth,load:auth', { user_id: user.id })
|
|
131
|
+
assert.strictEqual(direct.ok, true)
|
|
132
|
+
assert.strictEqual(direct.state, 'signedin')
|
|
133
|
+
assert.strictEqual(direct.user.pass, undefined)
|
|
134
|
+
assert.strictEqual(direct.user.salt, undefined)
|
|
135
|
+
|
|
136
|
+
const gone = await seneca.post('aim:auth,load:auth', { user_id: 'nope' })
|
|
137
|
+
assert.strictEqual(gone.ok, false)
|
|
138
|
+
assert.strictEqual(gone.state, 'signedout')
|
|
139
|
+
|
|
140
|
+
await seneca.close()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
test('update:user changes the profile of the signed-in user only', async () => {
|
|
145
|
+
const { seneca, user } = await withUser()
|
|
146
|
+
|
|
147
|
+
const out = await as(seneca, user,
|
|
148
|
+
{ aim: 'web', on: 'auth', update: 'user', data: { name: 'Alicia' } })
|
|
149
|
+
assert.strictEqual(out.ok, true)
|
|
150
|
+
|
|
151
|
+
const check = await seneca.post('sys:user,get:user', { email: 'alice@ex.com' })
|
|
152
|
+
assert.strictEqual(check.user.name, 'Alicia')
|
|
153
|
+
|
|
154
|
+
const anon = await seneca.post(
|
|
155
|
+
{ aim: 'web', on: 'auth', update: 'user', data: { name: 'Mallory' } })
|
|
156
|
+
assert.strictEqual(anon.ok, false)
|
|
157
|
+
assert.strictEqual(anon.why, 'not-authenticated')
|
|
158
|
+
|
|
159
|
+
await seneca.close()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
test('remind:pass never reveals whether the account exists', async () => {
|
|
164
|
+
const { seneca } = await withUser()
|
|
165
|
+
|
|
166
|
+
const known = await seneca.post(
|
|
167
|
+
{ aim: 'web', on: 'auth', remind: 'pass', email: 'alice@ex.com' })
|
|
168
|
+
const unknown = await seneca.post(
|
|
169
|
+
{ aim: 'web', on: 'auth', remind: 'pass', email: 'nobody@ex.com' })
|
|
170
|
+
|
|
171
|
+
// Same answer either way (no user enumeration), and nothing is sent.
|
|
172
|
+
assert.strictEqual(known.ok, true)
|
|
173
|
+
assert.strictEqual(unknown.ok, true)
|
|
174
|
+
assert.strictEqual(known.user, undefined)
|
|
175
|
+
assert.strictEqual(unknown.user, undefined)
|
|
176
|
+
|
|
177
|
+
await seneca.close()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
test('get:info reports the service', async () => {
|
|
182
|
+
const seneca = await makeSeneca()
|
|
183
|
+
const out = await seneca.post('aim:auth,get:info')
|
|
184
|
+
assert.strictEqual(out.ok, true)
|
|
185
|
+
assert.strictEqual(out.srv, 'auth')
|
|
186
|
+
await seneca.close()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
import Seneca from 'seneca'
|
|
3
|
+
|
|
4
|
+
import { entity } from '@voxgig/util'
|
|
5
|
+
|
|
6
|
+
import Model from '../../../../model/model.json'
|
|
7
|
+
|
|
8
|
+
const { base } = require('../../../../dist/env/shared/basic')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
// Build a Seneca instance with the generic entity service loaded from the
|
|
12
|
+
// compiled dist/srv. In-memory entity store, no external deps.
|
|
13
|
+
//
|
|
14
|
+
// @seneca/owner is loaded with the SAME options as the running app (from
|
|
15
|
+
// `base`): access control is enforced in the entity layer, so a test
|
|
16
|
+
// harness without it would exercise a system that cannot deny anything.
|
|
17
|
+
async function makeSeneca() {
|
|
18
|
+
const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
|
|
19
|
+
seneca.context.model = Model
|
|
20
|
+
seneca.context.env = 'test'
|
|
21
|
+
|
|
22
|
+
return seneca
|
|
23
|
+
.test()
|
|
24
|
+
.use('promisify')
|
|
25
|
+
.use('entity', {
|
|
26
|
+
strict: true,
|
|
27
|
+
ent: entity(Model),
|
|
28
|
+
})
|
|
29
|
+
.use('entity-util', {
|
|
30
|
+
when: { active: true },
|
|
31
|
+
})
|
|
32
|
+
.use('owner', base.options.owner)
|
|
33
|
+
.use('reload')
|
|
34
|
+
.use('../../../../dist/srv/ent/ent-srv')
|
|
35
|
+
.ready()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
// Post a message AS a given user, injecting the gateway-style principal into
|
|
40
|
+
// meta.custom (mirrors what @seneca/gateway-auth attaches from the cookie).
|
|
41
|
+
function as(seneca: any, user: any, msg: any) {
|
|
42
|
+
return seneca.post(Object.assign({}, msg, {
|
|
43
|
+
custom$: { principal: { user } },
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
makeSeneca,
|
|
50
|
+
as,
|
|
51
|
+
Model,
|
|
52
|
+
}
|
|
53
|
+
|