@pikku/inspector 0.12.49 → 0.12.52
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/CHANGELOG.md +501 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +156 -7
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +210 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +243 -13
- package/dist/utils/schema-generator.d.ts +24 -0
- package/dist/utils/schema-generator.js +118 -19
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +219 -9
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +214 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +310 -12
- package/src/utils/schema-cache.test.ts +124 -0
- package/src/utils/schema-generator.test.ts +62 -1
- package/src/utils/schema-generator.ts +138 -21
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger, MiddlewareGroupMeta } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
/** Every file that registered middleware for one pattern or tag. */
|
|
26
|
+
const sourceFiles = (group: MiddlewareGroupMeta | undefined): string[] =>
|
|
27
|
+
group
|
|
28
|
+
? [
|
|
29
|
+
group.sourceFile,
|
|
30
|
+
...(group.additionalRegistrations ?? []).map((r) => r.sourceFile),
|
|
31
|
+
]
|
|
32
|
+
: []
|
|
33
|
+
|
|
34
|
+
/** Runs the inspector over files written into a throwaway directory. */
|
|
35
|
+
const withProject = async (
|
|
36
|
+
prefix: string,
|
|
37
|
+
files: Record<string, string[]>,
|
|
38
|
+
assertions: (state: Awaited<ReturnType<typeof inspect>>) => void
|
|
39
|
+
) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), prefix))
|
|
41
|
+
const paths: string[] = []
|
|
42
|
+
for (const [name, lines] of Object.entries(files)) {
|
|
43
|
+
const path = join(rootDir, name)
|
|
44
|
+
await writeFile(path, lines.join('\n'))
|
|
45
|
+
paths.push(path)
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
assertions(await inspect(makeLogger([]), paths, { rootDir }))
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('addMiddleware inspector', () => {
|
|
55
|
+
describe('two files registering the same HTTP pattern', () => {
|
|
56
|
+
// The group is keyed by pattern, so the second call used to overwrite the
|
|
57
|
+
// first. Codegen emits imports from what is stored, so the losing file was
|
|
58
|
+
// never imported and its middleware never registered — an app could lose
|
|
59
|
+
// its session bridge to an unrelated '*' group and see nothing go wrong
|
|
60
|
+
// until a request arrived without a session.
|
|
61
|
+
const project = {
|
|
62
|
+
'session.ts': [
|
|
63
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
64
|
+
'export const sessionMiddleware = () =>',
|
|
65
|
+
" addHTTPMiddleware('*', [",
|
|
66
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
67
|
+
' ])',
|
|
68
|
+
],
|
|
69
|
+
'logging.ts': [
|
|
70
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
71
|
+
'export const loggingMiddleware = () =>',
|
|
72
|
+
" addHTTPMiddleware('*', [",
|
|
73
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
74
|
+
' ])',
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('keeps both registrations', async () => {
|
|
79
|
+
await withProject('pikku-mw-http-collide-', project, (state) => {
|
|
80
|
+
const group = state.http.routeMiddleware.get('*')
|
|
81
|
+
assert.ok(group, "expected a group for '*'")
|
|
82
|
+
assert.equal(
|
|
83
|
+
sourceFiles(group).length,
|
|
84
|
+
2,
|
|
85
|
+
'both files must survive, or one middleware silently stops running'
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('keeps both export names, so both can be imported', async () => {
|
|
91
|
+
await withProject('pikku-mw-http-names-', project, (state) => {
|
|
92
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
93
|
+
const names = [
|
|
94
|
+
group.exportName,
|
|
95
|
+
...(group.additionalRegistrations ?? []).map((r) => r.exportName),
|
|
96
|
+
]
|
|
97
|
+
assert.deepEqual(names.sort(), [
|
|
98
|
+
'loggingMiddleware',
|
|
99
|
+
'sessionMiddleware',
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('unions the services both registrations need', async () => {
|
|
105
|
+
await withProject('pikku-mw-http-services-', project, (state) => {
|
|
106
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
107
|
+
assert.equal(
|
|
108
|
+
new Set(group.services.services).size,
|
|
109
|
+
group.services.services.length,
|
|
110
|
+
'services must not repeat when two groups merge'
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('a single registration is unchanged', async () => {
|
|
117
|
+
await withProject(
|
|
118
|
+
'pikku-mw-http-single-',
|
|
119
|
+
{
|
|
120
|
+
'session.ts': [
|
|
121
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
122
|
+
'export const sessionMiddleware = () =>',
|
|
123
|
+
" addHTTPMiddleware('*', [",
|
|
124
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
125
|
+
' ])',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
(state) => {
|
|
129
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
130
|
+
assert.equal(group.exportName, 'sessionMiddleware')
|
|
131
|
+
assert.equal(
|
|
132
|
+
group.additionalRegistrations,
|
|
133
|
+
undefined,
|
|
134
|
+
'nothing to merge means nothing extra to serialize'
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('different patterns stay separate groups', async () => {
|
|
141
|
+
await withProject(
|
|
142
|
+
'pikku-mw-http-distinct-',
|
|
143
|
+
{
|
|
144
|
+
'session.ts': [
|
|
145
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
146
|
+
'export const sessionMiddleware = () =>',
|
|
147
|
+
" addHTTPMiddleware('*', [",
|
|
148
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
149
|
+
' ])',
|
|
150
|
+
],
|
|
151
|
+
'api.ts': [
|
|
152
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
153
|
+
'export const apiMiddleware = () =>',
|
|
154
|
+
" addHTTPMiddleware('/api/*', [",
|
|
155
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
156
|
+
' ])',
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
(state) => {
|
|
160
|
+
assert.equal(sourceFiles(state.http.routeMiddleware.get('*')).length, 1)
|
|
161
|
+
assert.equal(
|
|
162
|
+
sourceFiles(state.http.routeMiddleware.get('/api/*')).length,
|
|
163
|
+
1
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('two files registering the same tag keep both registrations', async () => {
|
|
170
|
+
await withProject(
|
|
171
|
+
'pikku-mw-tag-collide-',
|
|
172
|
+
{
|
|
173
|
+
'session.ts': [
|
|
174
|
+
"import { addTagMiddleware, pikkuMiddleware } from '#pikku'",
|
|
175
|
+
'export const sessionTag = () =>',
|
|
176
|
+
" addTagMiddleware('api', [",
|
|
177
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
178
|
+
' ])',
|
|
179
|
+
],
|
|
180
|
+
'logging.ts': [
|
|
181
|
+
"import { addTagMiddleware, pikkuMiddleware } from '#pikku'",
|
|
182
|
+
'export const loggingTag = () =>',
|
|
183
|
+
" addTagMiddleware('api', [",
|
|
184
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
185
|
+
' ])',
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
(state) => {
|
|
189
|
+
assert.equal(
|
|
190
|
+
sourceFiles(state.middleware.tagMiddleware.get('api')).length,
|
|
191
|
+
2
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AddWiring,
|
|
4
|
+
InspectorState,
|
|
5
|
+
MiddlewareGroupMeta,
|
|
6
|
+
} from '../types.js'
|
|
3
7
|
import {
|
|
4
8
|
extractFunctionName,
|
|
5
9
|
isNamedExport,
|
|
@@ -13,6 +17,62 @@ import { extractMiddlewareRefs } from '../utils/middleware.js'
|
|
|
13
17
|
import { getPropertyValue } from '../utils/get-property-value.js'
|
|
14
18
|
import { getPropertyAssignmentInitializer } from '../utils/type-utils.js'
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Record a middleware group, keeping any registration already stored under the
|
|
22
|
+
* same pattern or tag.
|
|
23
|
+
*
|
|
24
|
+
* Several files may add middleware for one pattern — an app's own `'*'` group
|
|
25
|
+
* alongside the generated better-auth session bridge, say — and the runtime
|
|
26
|
+
* composes them. A plain `set` kept only the last, and codegen emits imports
|
|
27
|
+
* from what is stored, so the other file was never imported and its middleware
|
|
28
|
+
* silently stopped running. Merging is the only shape that cannot lose a gate;
|
|
29
|
+
* the first registration stays primary so single-registration groups serialize
|
|
30
|
+
* exactly as before.
|
|
31
|
+
*/
|
|
32
|
+
function recordMiddlewareGroup(
|
|
33
|
+
groups: Map<string, MiddlewareGroupMeta>,
|
|
34
|
+
key: string,
|
|
35
|
+
group: MiddlewareGroupMeta
|
|
36
|
+
): void {
|
|
37
|
+
const existing = groups.get(key)
|
|
38
|
+
if (!existing) {
|
|
39
|
+
groups.set(key, group)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The same call seen twice — the inspector may revisit a file — is not a
|
|
44
|
+
// second registration, and appending it would emit a duplicate import.
|
|
45
|
+
const alreadyRecorded = [
|
|
46
|
+
existing,
|
|
47
|
+
...(existing.additionalRegistrations ?? []),
|
|
48
|
+
]
|
|
49
|
+
if (
|
|
50
|
+
alreadyRecorded.some(
|
|
51
|
+
(r) => r.sourceFile === group.sourceFile && r.position === group.position
|
|
52
|
+
)
|
|
53
|
+
) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
existing.additionalRegistrations = [
|
|
58
|
+
...(existing.additionalRegistrations ?? []),
|
|
59
|
+
{
|
|
60
|
+
exportName: group.exportName,
|
|
61
|
+
sourceFile: group.sourceFile,
|
|
62
|
+
position: group.position,
|
|
63
|
+
isFactory: group.isFactory,
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
existing.count += group.count
|
|
67
|
+
existing.instanceIds = [...existing.instanceIds, ...group.instanceIds]
|
|
68
|
+
existing.services = {
|
|
69
|
+
optimized: false,
|
|
70
|
+
services: Array.from(
|
|
71
|
+
new Set([...existing.services.services, ...group.services.services])
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
16
76
|
function renameTempDefinitions(
|
|
17
77
|
state: InspectorState,
|
|
18
78
|
definitionIds: string[],
|
|
@@ -333,7 +393,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
333
393
|
)
|
|
334
394
|
}
|
|
335
395
|
|
|
336
|
-
state.middleware.tagMiddleware
|
|
396
|
+
recordMiddlewareGroup(state.middleware.tagMiddleware, tag, {
|
|
337
397
|
exportName: exportedName,
|
|
338
398
|
sourceFile,
|
|
339
399
|
position: node.getStart(),
|
|
@@ -477,7 +537,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
477
537
|
)
|
|
478
538
|
}
|
|
479
539
|
|
|
480
|
-
state.http.routeMiddleware
|
|
540
|
+
recordMiddlewareGroup(state.http.routeMiddleware, pattern, {
|
|
481
541
|
exportName: exportedName,
|
|
482
542
|
sourceFile,
|
|
483
543
|
position: node.getStart(),
|
|
@@ -796,7 +856,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
796
856
|
)
|
|
797
857
|
}
|
|
798
858
|
|
|
799
|
-
state.channelMiddleware.tagMiddleware
|
|
859
|
+
recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
|
|
800
860
|
exportName: exportedName,
|
|
801
861
|
sourceFile,
|
|
802
862
|
position: node.getStart(),
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
const inspectSource = async (source: string) => {
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-personas-'))
|
|
27
|
+
const file = join(rootDir, 'personas.ts')
|
|
28
|
+
await writeFile(file, source)
|
|
29
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
30
|
+
try {
|
|
31
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
32
|
+
return { state, criticals }
|
|
33
|
+
} finally {
|
|
34
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The declared world every persona below is checked against. */
|
|
39
|
+
const withRoles = (...lines: string[]) =>
|
|
40
|
+
[
|
|
41
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
42
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
43
|
+
"import { definePersonas } from '@pikku/core/persona'",
|
|
44
|
+
'defineScope({ catalogue: { scopes: { read: {} } }, admin: {} })',
|
|
45
|
+
'defineSystemRole({',
|
|
46
|
+
" buyer: { scopes: ['catalogue:read'] },",
|
|
47
|
+
" admin: { scopes: ['admin'] },",
|
|
48
|
+
'})',
|
|
49
|
+
...lines,
|
|
50
|
+
].join('\n')
|
|
51
|
+
|
|
52
|
+
describe('addPersonas inspector', () => {
|
|
53
|
+
test('extracts a whole person', async () => {
|
|
54
|
+
const { state, criticals } = await inspectSource(
|
|
55
|
+
withRoles(
|
|
56
|
+
'definePersonas({',
|
|
57
|
+
' susan: {',
|
|
58
|
+
" name: 'Susan',",
|
|
59
|
+
" jobTitle: 'Buys for a small café',",
|
|
60
|
+
" roles: ['buyer'],",
|
|
61
|
+
" personality: 'Hunts cheap deals.',",
|
|
62
|
+
" goals: ['Get the weekly order in under five minutes'],",
|
|
63
|
+
" tags: ['commerce'],",
|
|
64
|
+
" disposition: 'careless',",
|
|
65
|
+
' tuning: { repeatRate: 0.3 },',
|
|
66
|
+
" fixtures: ['seedCatalogue'],",
|
|
67
|
+
' account: {},',
|
|
68
|
+
' },',
|
|
69
|
+
'})'
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(criticals, [])
|
|
74
|
+
assert.equal(state.personas.definitions.length, 1)
|
|
75
|
+
const susan = state.personas.definitions[0]!
|
|
76
|
+
assert.equal(susan.id, 'susan')
|
|
77
|
+
assert.equal(susan.name, 'Susan')
|
|
78
|
+
assert.equal(susan.jobTitle, 'Buys for a small café')
|
|
79
|
+
assert.deepEqual(susan.roles, ['buyer'])
|
|
80
|
+
assert.equal(susan.personality, 'Hunts cheap deals.')
|
|
81
|
+
assert.deepEqual(susan.goals, [
|
|
82
|
+
'Get the weekly order in under five minutes',
|
|
83
|
+
])
|
|
84
|
+
assert.deepEqual(susan.tags, ['commerce'])
|
|
85
|
+
assert.equal(susan.disposition, 'careless')
|
|
86
|
+
assert.deepEqual(susan.tuning, { repeatRate: 0.3 })
|
|
87
|
+
assert.deepEqual(susan.fixtures, ['seedCatalogue'])
|
|
88
|
+
assert.deepEqual(susan.account, {})
|
|
89
|
+
assert.equal(susan.runnable, true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('extracts the environments a persona declares', async () => {
|
|
93
|
+
const { state } = await inspectSource(
|
|
94
|
+
withRoles(
|
|
95
|
+
'definePersonas({',
|
|
96
|
+
' robin: {',
|
|
97
|
+
" name: 'Robin',",
|
|
98
|
+
" disposition: 'accountable',",
|
|
99
|
+
" environments: ['staging', 'prod'],",
|
|
100
|
+
' },',
|
|
101
|
+
'})'
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
assert.deepEqual(state.personas.definitions[0]!.environments, [
|
|
105
|
+
'staging',
|
|
106
|
+
'prod',
|
|
107
|
+
])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// "Everywhere but production" is a fact about the config, and the meta file
|
|
111
|
+
// outlives whichever environments were configured the day it was written —
|
|
112
|
+
// so the absence is preserved rather than resolved into a list here.
|
|
113
|
+
test('leaves environments absent when none are declared', async () => {
|
|
114
|
+
const { state } = await inspectSource(
|
|
115
|
+
withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
|
|
116
|
+
)
|
|
117
|
+
assert.equal(state.personas.definitions[0]!.environments, undefined)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('the declaration key is the id', async () => {
|
|
121
|
+
const { state } = await inspectSource(
|
|
122
|
+
withRoles(
|
|
123
|
+
'definePersonas({',
|
|
124
|
+
" susan: { name: 'Susan' },",
|
|
125
|
+
" mallory: { name: 'Mallory' },",
|
|
126
|
+
'})'
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
state.personas.definitions.map((p) => p.id),
|
|
131
|
+
['susan', 'mallory']
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a persona without a name is refused', async () => {
|
|
136
|
+
const { state, criticals } = await inspectSource(
|
|
137
|
+
withRoles('definePersonas({', " susan: { roles: ['buyer'] },", '})')
|
|
138
|
+
)
|
|
139
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
140
|
+
assert.ok(criticals.some((c) => /needs a literal 'name'/.test(c.message)))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('an unknown disposition is refused rather than defaulted', async () => {
|
|
144
|
+
const { state, criticals } = await inspectSource(
|
|
145
|
+
withRoles(
|
|
146
|
+
'definePersonas({',
|
|
147
|
+
" susan: { name: 'Susan', disposition: 'grumpy' },",
|
|
148
|
+
'})'
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
152
|
+
assert.ok(
|
|
153
|
+
criticals.some((c) => /unknown disposition 'grumpy'/.test(c.message))
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// `repeatRate: 18` meaning 18% would silently double every call, and the run
|
|
158
|
+
// would read as a product bug.
|
|
159
|
+
test('a tuning dial outside its range is refused', async () => {
|
|
160
|
+
const { state, criticals } = await inspectSource(
|
|
161
|
+
withRoles(
|
|
162
|
+
'definePersonas({',
|
|
163
|
+
" susan: { name: 'Susan', tuning: { repeatRate: 18 } },",
|
|
164
|
+
'})'
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
168
|
+
assert.ok(
|
|
169
|
+
criticals.some((c) =>
|
|
170
|
+
/repeatRate must be between 0 and 1/.test(c.message)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a linked account is extracted alongside the primary one', async () => {
|
|
176
|
+
const { state, criticals } = await inspectSource(
|
|
177
|
+
withRoles(
|
|
178
|
+
'definePersonas({',
|
|
179
|
+
' yasser: {',
|
|
180
|
+
" name: 'Yasser',",
|
|
181
|
+
' account: {},',
|
|
182
|
+
" linkedAccounts: { google: { provider: 'google' } },",
|
|
183
|
+
' },',
|
|
184
|
+
'})'
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
assert.deepEqual(criticals, [])
|
|
188
|
+
const yasser = state.personas.definitions[0]!
|
|
189
|
+
assert.deepEqual(yasser.account, {})
|
|
190
|
+
assert.deepEqual(yasser.linkedAccounts, { google: { provider: 'google' } })
|
|
191
|
+
// Their primary login is email+password, so they can still be driven.
|
|
192
|
+
assert.equal(yasser.runnable, true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Driving a consent screen needs a human — refused at the CLI rather than
|
|
196
|
+
// failing somewhere inside a browser.
|
|
197
|
+
test('a persona whose own login is a provider is not runnable', async () => {
|
|
198
|
+
const { state } = await inspectSource(
|
|
199
|
+
withRoles(
|
|
200
|
+
'definePersonas({',
|
|
201
|
+
" yasser: { name: 'Yasser', account: { provider: 'google' } },",
|
|
202
|
+
'})'
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
assert.equal(state.personas.definitions[0]!.runnable, false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('a person who is only ever acted upon says so', async () => {
|
|
209
|
+
const { state } = await inspectSource(
|
|
210
|
+
withRoles(
|
|
211
|
+
'definePersonas({',
|
|
212
|
+
" target: { name: 'Target', account: {}, runnable: false },",
|
|
213
|
+
'})'
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
assert.equal(state.personas.definitions[0]!.runnable, false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// A half-read roles array typechecks, runs, and grants less than the source
|
|
220
|
+
// says — so an unreadable one reads as absent.
|
|
221
|
+
test('a computed roles array is not half-extracted', async () => {
|
|
222
|
+
const { state } = await inspectSource(
|
|
223
|
+
withRoles(
|
|
224
|
+
'const r = "buyer"',
|
|
225
|
+
'definePersonas({',
|
|
226
|
+
" susan: { name: 'Susan', roles: ['buyer', r] },",
|
|
227
|
+
'})'
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
assert.deepEqual(state.personas.definitions[0]!.roles, [])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('validatePersonaRoles', () => {
|
|
235
|
+
test('a declared system role is accepted', async () => {
|
|
236
|
+
const { criticals } = await inspectSource(
|
|
237
|
+
withRoles(
|
|
238
|
+
'definePersonas({',
|
|
239
|
+
" susan: { name: 'Susan', roles: ['buyer', 'admin'] },",
|
|
240
|
+
'})'
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
assert.deepEqual(criticals, [])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// The failure this prevents: an admin deletes 'invoicing-clerk' from the
|
|
247
|
+
// console, and Susan goes on claiming to test it while 403ing everywhere.
|
|
248
|
+
test('a role that is not declared fails the build', async () => {
|
|
249
|
+
const { criticals } = await inspectSource(
|
|
250
|
+
withRoles(
|
|
251
|
+
'definePersonas({',
|
|
252
|
+
" susan: { name: 'Susan', roles: ['invoicing-clerk'] },",
|
|
253
|
+
'})'
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
const critical = criticals.find((c) => /invoicing-clerk/.test(c.message))
|
|
257
|
+
assert.ok(critical, `expected a critical, got ${JSON.stringify(criticals)}`)
|
|
258
|
+
assert.match(critical!.message, /may only name a system role/)
|
|
259
|
+
assert.match(critical!.message, /admin, buyer/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('a persona with no roles at all is fine', async () => {
|
|
263
|
+
const { criticals } = await inspectSource(
|
|
264
|
+
withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
|
|
265
|
+
)
|
|
266
|
+
assert.deepEqual(criticals, [])
|
|
267
|
+
})
|
|
268
|
+
})
|