@pikku/inspector 0.12.52 → 0.12.55
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 +341 -0
- package/dist/add/add-functions.js +46 -4
- package/dist/add/add-keyed-wiring.js +3 -1
- package/dist/add/add-personas.js +7 -1
- package/dist/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +8 -1
- package/dist/error-codes.js +12 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -0
- package/dist/utils/post-process.js +4 -1
- package/dist/utils/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.js +15 -7
- package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- package/package.json +3 -3
- package/src/add/add-functions.ts +57 -5
- package/src/add/add-keyed-wiring.ts +3 -0
- package/src/add/add-personas.test.ts +115 -1
- package/src/add/add-personas.ts +18 -9
- package/src/add/add-scenario-step.test.ts +38 -2
- package/src/add/add-scope.test.ts +73 -0
- package/src/add/add-system-role.test.ts +83 -0
- package/src/add/add-workflow.ts +21 -1
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/error-codes.ts +13 -0
- package/src/types.ts +9 -0
- package/src/utils/check-secret-sinks.ts +145 -0
- package/src/utils/post-process.ts +4 -1
- package/src/utils/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +2 -2
- package/src/utils/validate-exposed-functions-gated.ts +16 -7
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { inspect } from '../inspector.js'
|
|
7
|
-
import
|
|
7
|
+
import { ErrorCode } from '../error-codes.js'
|
|
8
8
|
import type { InspectorLogger } from '../types.js'
|
|
9
9
|
|
|
10
10
|
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Inspects several files at once, keyed by basename. */
|
|
39
|
+
const inspectSources = async (sources: Record<string, string>) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-personas-multi-'))
|
|
41
|
+
const files: Record<string, string> = {}
|
|
42
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
43
|
+
files[name] = join(rootDir, name)
|
|
44
|
+
await writeFile(files[name]!, source)
|
|
45
|
+
}
|
|
46
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
47
|
+
try {
|
|
48
|
+
const state = await inspect(makeLogger(criticals), Object.values(files), {
|
|
49
|
+
rootDir,
|
|
50
|
+
})
|
|
51
|
+
return { state, criticals, files }
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** The declared world every persona below is checked against. */
|
|
39
58
|
const withRoles = (...lines: string[]) =>
|
|
40
59
|
[
|
|
@@ -89,6 +108,30 @@ describe('addPersonas inspector', () => {
|
|
|
89
108
|
assert.equal(susan.runnable, true)
|
|
90
109
|
})
|
|
91
110
|
|
|
111
|
+
test('extracts a declared avatar', async () => {
|
|
112
|
+
const { state } = await inspectSource(
|
|
113
|
+
withRoles(
|
|
114
|
+
'definePersonas({',
|
|
115
|
+
' susan: {',
|
|
116
|
+
" name: 'Susan',",
|
|
117
|
+
" avatarUrl: '/avatars/susan.png',",
|
|
118
|
+
' },',
|
|
119
|
+
'})'
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
assert.equal(state.personas.definitions[0]!.avatarUrl, '/avatars/susan.png')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// The console draws a colour-and-icon avatar from the persona's id when none
|
|
126
|
+
// is declared, so an absent field has to stay absent rather than become an
|
|
127
|
+
// empty string the renderer would treat as a broken image.
|
|
128
|
+
test('leaves the avatar absent when none is declared', async () => {
|
|
129
|
+
const { state } = await inspectSource(
|
|
130
|
+
withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
|
|
131
|
+
)
|
|
132
|
+
assert.equal(state.personas.definitions[0]!.avatarUrl, undefined)
|
|
133
|
+
})
|
|
134
|
+
|
|
92
135
|
test('extracts the environments a persona declares', async () => {
|
|
93
136
|
const { state } = await inspectSource(
|
|
94
137
|
withRoles(
|
|
@@ -216,6 +259,77 @@ describe('addPersonas inspector', () => {
|
|
|
216
259
|
assert.equal(state.personas.definitions[0]!.runnable, false)
|
|
217
260
|
})
|
|
218
261
|
|
|
262
|
+
// One call site per codebase, so there is one place to read the cast from
|
|
263
|
+
// and one place to add to. The keyed form above is how you declare more.
|
|
264
|
+
test('a second definePersonas in the same file is refused', async () => {
|
|
265
|
+
const { state, criticals } = await inspectSource(
|
|
266
|
+
withRoles(
|
|
267
|
+
"definePersonas({ susan: { name: 'Susan' } })",
|
|
268
|
+
"definePersonas({ dave: { name: 'Dave' } })"
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const hit = criticals.find(
|
|
273
|
+
(c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
|
|
274
|
+
)
|
|
275
|
+
assert.ok(
|
|
276
|
+
hit,
|
|
277
|
+
`expected DUPLICATE_PERSONAS_DEFINITION, got ${JSON.stringify(criticals)}`
|
|
278
|
+
)
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
state.personas.definitions.map((p) => p.id),
|
|
281
|
+
['susan']
|
|
282
|
+
)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('a second definePersonas in another file is refused', async () => {
|
|
286
|
+
const { criticals, files } = await inspectSources({
|
|
287
|
+
'personas.ts': withRoles("definePersonas({ susan: { name: 'Susan' } })"),
|
|
288
|
+
'more-personas.ts': [
|
|
289
|
+
"import { definePersonas } from '@pikku/core/persona'",
|
|
290
|
+
"definePersonas({ dave: { name: 'Dave' } })",
|
|
291
|
+
].join('\n'),
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const hit = criticals.find(
|
|
295
|
+
(c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
|
|
296
|
+
)
|
|
297
|
+
assert.ok(
|
|
298
|
+
hit,
|
|
299
|
+
`expected DUPLICATE_PERSONAS_DEFINITION, got ${JSON.stringify(criticals)}`
|
|
300
|
+
)
|
|
301
|
+
assert.ok(
|
|
302
|
+
hit!.message.includes(files['personas.ts']!) &&
|
|
303
|
+
hit!.message.includes(files['more-personas.ts']!),
|
|
304
|
+
`expected both files named, got ${hit!.message}`
|
|
305
|
+
)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// The CLI scaffolds personas of its own, and nobody adds a persona to a file
|
|
309
|
+
// the next codegen run overwrites — so a generated declaration neither takes
|
|
310
|
+
// the single slot nor collides with the app's own.
|
|
311
|
+
test('a generated declaration neither claims the slot nor conflicts', async () => {
|
|
312
|
+
const { state, criticals } = await inspectSources({
|
|
313
|
+
'pikku-personas.gen.ts': [
|
|
314
|
+
"import { definePersonas } from '@pikku/core/persona'",
|
|
315
|
+
"definePersonas({ dave: { name: 'Dave' } })",
|
|
316
|
+
].join('\n'),
|
|
317
|
+
'personas.ts': withRoles("definePersonas({ susan: { name: 'Susan' } })"),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
assert.deepEqual(
|
|
321
|
+
criticals.filter(
|
|
322
|
+
(c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
|
|
323
|
+
),
|
|
324
|
+
[],
|
|
325
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
326
|
+
)
|
|
327
|
+
assert.deepEqual(state.personas.definitions.map((d) => d.id).sort(), [
|
|
328
|
+
'dave',
|
|
329
|
+
'susan',
|
|
330
|
+
])
|
|
331
|
+
})
|
|
332
|
+
|
|
219
333
|
// A half-read roles array typechecks, runs, and grants less than the source
|
|
220
334
|
// says — so an unreadable one reads as absent.
|
|
221
335
|
test('a computed roles array is not half-extracted', async () => {
|
package/src/add/add-personas.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { DISPOSITIONS } from '@pikku/core/virtual-user'
|
|
3
3
|
import { isRunnablePersona } from '@pikku/core/persona'
|
|
4
|
-
import type {
|
|
5
|
-
PersonaAccountMeta,
|
|
6
|
-
PersonaMeta,
|
|
7
|
-
} from '@pikku/core/persona'
|
|
4
|
+
import type { PersonaAccountMeta, PersonaMeta } from '@pikku/core/persona'
|
|
8
5
|
import { ErrorCode } from '../error-codes.js'
|
|
6
|
+
import { claimSingleDeclaration } from '../utils/single-declaration.js'
|
|
9
7
|
import type { AddWiring, InspectorLogger } from '../types.js'
|
|
10
8
|
import {
|
|
11
9
|
booleanProperty,
|
|
@@ -172,6 +170,19 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
|
|
|
172
170
|
}
|
|
173
171
|
|
|
174
172
|
const sourceFile = node.getSourceFile().fileName
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
!claimSingleDeclaration(
|
|
176
|
+
logger,
|
|
177
|
+
state.personas.files,
|
|
178
|
+
ErrorCode.DUPLICATE_PERSONAS_DEFINITION,
|
|
179
|
+
'definePersonas',
|
|
180
|
+
sourceFile
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
175
186
|
const known = Object.keys(DISPOSITIONS)
|
|
176
187
|
|
|
177
188
|
for (const prop of unwrapped.properties) {
|
|
@@ -222,10 +233,7 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
|
|
|
222
233
|
let tuningFailed = false
|
|
223
234
|
const tuning = tuningProperty(config, (message) => {
|
|
224
235
|
tuningFailed = true
|
|
225
|
-
logger.critical(
|
|
226
|
-
ErrorCode.INVALID_VALUE,
|
|
227
|
-
`Persona '${id}': ${message}`
|
|
228
|
-
)
|
|
236
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}': ${message}`)
|
|
229
237
|
})
|
|
230
238
|
if (tuningFailed) {
|
|
231
239
|
continue
|
|
@@ -253,6 +261,8 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
|
|
|
253
261
|
if (jobTitle !== undefined) persona.jobTitle = jobTitle
|
|
254
262
|
const description = stringProperty(config, 'description')
|
|
255
263
|
if (description !== undefined) persona.description = description
|
|
264
|
+
const avatarUrl = stringProperty(config, 'avatarUrl')
|
|
265
|
+
if (avatarUrl !== undefined) persona.avatarUrl = avatarUrl
|
|
256
266
|
const personality = stringProperty(config, 'personality')
|
|
257
267
|
if (personality !== undefined) persona.personality = personality
|
|
258
268
|
if (disposition !== undefined) {
|
|
@@ -270,7 +280,6 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
|
|
|
270
280
|
const linkedAccounts = linkedAccountsProperty(config, id, logger)
|
|
271
281
|
if (linkedAccounts !== undefined) persona.linkedAccounts = linkedAccounts
|
|
272
282
|
|
|
273
|
-
state.personas.files.add(sourceFile)
|
|
274
283
|
state.personas.definitions.push(persona)
|
|
275
284
|
}
|
|
276
285
|
}
|
|
@@ -144,7 +144,7 @@ describe('pikkuScenarioStep', () => {
|
|
|
144
144
|
|
|
145
145
|
test('the step target is bundled, so the step function is wired', async () => {
|
|
146
146
|
const { state, cleanup } = await run([
|
|
147
|
-
"await scenario.
|
|
147
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
148
148
|
])
|
|
149
149
|
try {
|
|
150
150
|
assert.ok(
|
|
@@ -183,7 +183,7 @@ describe('pikkuScenarioStep', () => {
|
|
|
183
183
|
test('a non-literal step target is a PKU678 critical', async () => {
|
|
184
184
|
const { criticals, cleanup } = await run([
|
|
185
185
|
'const target = Math.random() > 0.5 ? "buysAnApple" : "seesAReceipt"',
|
|
186
|
-
"await scenario.
|
|
186
|
+
"await scenario.when('buys something', target as any, {}, { actor: actors.shopper })",
|
|
187
187
|
])
|
|
188
188
|
try {
|
|
189
189
|
assert.ok(
|
|
@@ -224,6 +224,42 @@ describe('pikkuScenarioStep', () => {
|
|
|
224
224
|
}
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
test('a scenario whose witness is an expectation helper is not flagged', async () => {
|
|
228
|
+
// `expectService` is an inline step and carries no phase, so it reaches
|
|
229
|
+
// PKU680 as nothing at all — but a recorded service call is a witness, and
|
|
230
|
+
// a flow that has one is not the assertion-free flow the rule is for.
|
|
231
|
+
const { criticals, cleanup } = await run([
|
|
232
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
233
|
+
"await scenario.expectService('the receipt was emailed', 'emailService.send', { actor: actors.shopper })",
|
|
234
|
+
])
|
|
235
|
+
try {
|
|
236
|
+
assert.equal(
|
|
237
|
+
criticals.filter((c) => c.code === 'PKU680').length,
|
|
238
|
+
0,
|
|
239
|
+
`got: ${JSON.stringify(criticals)}`
|
|
240
|
+
)
|
|
241
|
+
} finally {
|
|
242
|
+
await cleanup()
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('an expectation parked in an uncalled helper is still no assertion', async () => {
|
|
247
|
+
const { criticals, cleanup } = await run([
|
|
248
|
+
'const neverCalled = async () => {',
|
|
249
|
+
" await scenario.expectService('the receipt was emailed', 'emailService.send')",
|
|
250
|
+
'}',
|
|
251
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
252
|
+
])
|
|
253
|
+
try {
|
|
254
|
+
assert.ok(
|
|
255
|
+
criticals.find((c) => c.code === 'PKU680'),
|
|
256
|
+
`expected PKU680 — the helper never runs, so nothing is asserted; got: ${JSON.stringify(criticals)}`
|
|
257
|
+
)
|
|
258
|
+
} finally {
|
|
259
|
+
await cleanup()
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
227
263
|
test('a scenario with an assertion is not flagged', async () => {
|
|
228
264
|
const { criticals, cleanup } = await run([
|
|
229
265
|
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Inspects several files at once, keyed by basename. */
|
|
39
|
+
const inspectSources = async (sources: Record<string, string>) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-scope-multi-'))
|
|
41
|
+
const files: Record<string, string> = {}
|
|
42
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
43
|
+
files[name] = join(rootDir, name)
|
|
44
|
+
await writeFile(files[name]!, source)
|
|
45
|
+
}
|
|
46
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
47
|
+
try {
|
|
48
|
+
const state = await inspect(makeLogger(criticals), Object.values(files), {
|
|
49
|
+
rootDir,
|
|
50
|
+
})
|
|
51
|
+
return { state, criticals, files }
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
describe('addScope inspector', () => {
|
|
39
58
|
test('extracts a flat scope', async () => {
|
|
40
59
|
const { state, criticals } = await inspectSource(
|
|
@@ -162,6 +181,60 @@ describe('addScope inspector', () => {
|
|
|
162
181
|
)
|
|
163
182
|
})
|
|
164
183
|
|
|
184
|
+
// The CLI generates a `defineScope` of its own — `user-admin.gen.ts` ships
|
|
185
|
+
// the whole `admin` tree, and `@pikku/addon-console` spells it out again — so
|
|
186
|
+
// a project that scaffolds user-admin declares scopes in more than one file
|
|
187
|
+
// and every one of them has to survive.
|
|
188
|
+
test('declarations in separate files all survive', async () => {
|
|
189
|
+
const { state, criticals } = await inspectSources({
|
|
190
|
+
'user-admin.gen.ts': [
|
|
191
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
192
|
+
'defineScope({ admin: {} })',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
'scopes.ts': [
|
|
195
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
196
|
+
'defineScope({ billing: {} })',
|
|
197
|
+
].join('\n'),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
criticals.filter((c) => /Only one defineScope/.test(c.message)),
|
|
202
|
+
[],
|
|
203
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
204
|
+
)
|
|
205
|
+
assert.deepEqual(
|
|
206
|
+
state.scopes.definitions.map((d) => d.name).sort(),
|
|
207
|
+
['admin', 'billing']
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// The CLI scaffolds a scope tree of its own — `user-admin.gen.ts` declares the
|
|
212
|
+
// whole `admin` tree — so a generated declaration cannot be made to compete
|
|
213
|
+
// with the app's own for the single slot.
|
|
214
|
+
test('a generated declaration neither claims the slot nor conflicts', async () => {
|
|
215
|
+
const { state, criticals } = await inspectSources({
|
|
216
|
+
'user-admin.gen.ts': [
|
|
217
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
218
|
+
'defineScope({ admin: {} })',
|
|
219
|
+
].join('\n'),
|
|
220
|
+
'scopes.ts': [
|
|
221
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
222
|
+
'defineScope({ billing: {} })',
|
|
223
|
+
].join('\n'),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
criticals.filter((c) => c.code === ErrorCode.DUPLICATE_SCOPE_DEFINITION),
|
|
228
|
+
[],
|
|
229
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
230
|
+
)
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
state.scopes.definitions.map((d) => d.name).sort(),
|
|
233
|
+
['admin', 'billing'],
|
|
234
|
+
"both the generated tree and the app's own must survive"
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
165
238
|
test('is critical when a root embeds the separator', async () => {
|
|
166
239
|
const { criticals } = await inspectSource(
|
|
167
240
|
[
|
|
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Inspects several files at once, keyed by basename. */
|
|
39
|
+
const inspectSources = async (sources: Record<string, string>) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-role-multi-'))
|
|
41
|
+
const files: Record<string, string> = {}
|
|
42
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
43
|
+
files[name] = join(rootDir, name)
|
|
44
|
+
await writeFile(files[name]!, source)
|
|
45
|
+
}
|
|
46
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
47
|
+
try {
|
|
48
|
+
const state = await inspect(makeLogger(criticals), Object.values(files), {
|
|
49
|
+
rootDir,
|
|
50
|
+
})
|
|
51
|
+
return { state, criticals, files }
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
const IMPORTS = [
|
|
39
58
|
"import { defineScope } from '@pikku/core/scope'",
|
|
40
59
|
"import { defineSystemRole } from '@pikku/core/role'",
|
|
@@ -91,6 +110,70 @@ describe('addSystemRole inspector', () => {
|
|
|
91
110
|
)
|
|
92
111
|
})
|
|
93
112
|
|
|
113
|
+
test('extracts roles from several calls in one file', async () => {
|
|
114
|
+
const { state } = await inspectSource(
|
|
115
|
+
withScopes(
|
|
116
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })",
|
|
117
|
+
"defineSystemRole({ admin: { scopes: ['admin'] } })"
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
state.systemRoles.definitions.map((r) => r.name).sort(),
|
|
123
|
+
['admin', 'buyer']
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Roles follow scopes: a project scaffolding user-admin has a generated
|
|
128
|
+
// declaration beside its own, so every file's roles have to survive.
|
|
129
|
+
test('declarations in separate files all survive', async () => {
|
|
130
|
+
const { state, criticals } = await inspectSources({
|
|
131
|
+
'roles.ts': withScopes(
|
|
132
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
|
|
133
|
+
),
|
|
134
|
+
'more-roles.ts': [
|
|
135
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
136
|
+
"defineSystemRole({ admin: { scopes: ['admin'] } })",
|
|
137
|
+
].join('\n'),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(
|
|
141
|
+
criticals.filter((c) => /Only one defineSystemRole/.test(c.message)),
|
|
142
|
+
[],
|
|
143
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
144
|
+
)
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
state.systemRoles.definitions.map((r) => r.name).sort(),
|
|
147
|
+
['admin', 'buyer']
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// A generated file is not the one place a person adds a role to, so it neither
|
|
152
|
+
// takes the single slot nor collides with the app's own declaration.
|
|
153
|
+
test('a generated declaration neither claims the slot nor conflicts', async () => {
|
|
154
|
+
const { state, criticals } = await inspectSources({
|
|
155
|
+
'roles.gen.ts': [
|
|
156
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
157
|
+
'defineSystemRole({ admin: { scopes: [] } })',
|
|
158
|
+
].join('\n'),
|
|
159
|
+
'roles.ts': withScopes(
|
|
160
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
criticals.filter(
|
|
166
|
+
(c) => c.code === ErrorCode.DUPLICATE_SYSTEM_ROLE_DEFINITION
|
|
167
|
+
),
|
|
168
|
+
[],
|
|
169
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
170
|
+
)
|
|
171
|
+
assert.deepEqual(state.systemRoles.definitions.map((r) => r.name).sort(), [
|
|
172
|
+
'admin',
|
|
173
|
+
'buyer',
|
|
174
|
+
])
|
|
175
|
+
})
|
|
176
|
+
|
|
94
177
|
test('a role granting nothing is legitimate, said explicitly', async () => {
|
|
95
178
|
const { state, criticals } = await inspectSource(
|
|
96
179
|
withScopes('defineSystemRole({', ' guest: { scopes: [] },', '})')
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -229,7 +229,13 @@ function getWorkflowInvocations(
|
|
|
229
229
|
* Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
|
|
230
230
|
* pikkuScenario() calls. Detects workflow registration and extracts metadata.
|
|
231
231
|
*/
|
|
232
|
-
export const addWorkflow: AddWiring = (
|
|
232
|
+
export const addWorkflow: AddWiring = (
|
|
233
|
+
logger,
|
|
234
|
+
node,
|
|
235
|
+
checker,
|
|
236
|
+
state,
|
|
237
|
+
options
|
|
238
|
+
) => {
|
|
233
239
|
if (!ts.isCallExpression(node)) {
|
|
234
240
|
return
|
|
235
241
|
}
|
|
@@ -246,6 +252,17 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
246
252
|
if (expression.text === 'pikkuWorkflowFunc') {
|
|
247
253
|
wrapperType = 'dsl'
|
|
248
254
|
} else if (expression.text === 'pikkuWorkflowComplexFunc') {
|
|
255
|
+
if (!options.allow?.complexWorkflows) {
|
|
256
|
+
logger.critical(
|
|
257
|
+
ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED,
|
|
258
|
+
`'${expression.text}' is used here, but this project has not enabled complex workflows. ` +
|
|
259
|
+
`Their inline steps cannot be serialized, so they are absent from the workflow graph and ` +
|
|
260
|
+
`cannot be replayed or migrated across a definition change. Express the workflow with ` +
|
|
261
|
+
`pikkuWorkflowFunc and the step DSL, or set \`allow: { complexWorkflows: true }\` in ` +
|
|
262
|
+
`pikku.config.json if the project accepts that trade.`
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
249
266
|
wrapperType = 'complex'
|
|
250
267
|
} else if (expression.text === 'pikkuScenario') {
|
|
251
268
|
// A scenario is a complex workflow whose steps run as actors over the
|
|
@@ -351,6 +368,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
351
368
|
let steps: WorkflowStepMeta[] = []
|
|
352
369
|
let context: WorkflowContext | undefined = undefined
|
|
353
370
|
let dsl: boolean | undefined = undefined
|
|
371
|
+
let asserts: boolean | undefined = undefined
|
|
354
372
|
|
|
355
373
|
// Try DSL workflow extraction first
|
|
356
374
|
// Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
|
|
@@ -362,6 +380,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
362
380
|
// Extraction succeeded
|
|
363
381
|
steps = result.steps
|
|
364
382
|
context = result.context
|
|
383
|
+
asserts = result.asserts
|
|
365
384
|
|
|
366
385
|
// Check if workflow contains inline steps (non-serializable)
|
|
367
386
|
if (hasInlineSteps(steps)) {
|
|
@@ -449,6 +468,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
449
468
|
expose,
|
|
450
469
|
skip,
|
|
451
470
|
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
471
|
+
asserts,
|
|
452
472
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
453
473
|
}
|
|
454
474
|
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger, InspectorOptions } from '../types.js'
|
|
9
|
+
|
|
10
|
+
function makeLogger(
|
|
11
|
+
criticals: Array<{ code: string; message: string }>
|
|
12
|
+
): InspectorLogger {
|
|
13
|
+
return {
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
diagnostic: ({ code, message }: any) => criticals.push({ code, message }),
|
|
19
|
+
critical: (code: any, message: string) => criticals.push({ code, message }),
|
|
20
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
21
|
+
} as InspectorLogger
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(source: string, allow: InspectorOptions['allow'] = {}) {
|
|
25
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-allow-'))
|
|
26
|
+
const file = join(rootDir, 'subject.ts')
|
|
27
|
+
await writeFile(file, source)
|
|
28
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
29
|
+
const state = await inspect(makeLogger(criticals), [file], {
|
|
30
|
+
rootDir,
|
|
31
|
+
allow,
|
|
32
|
+
})
|
|
33
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
34
|
+
return { state, criticals }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const has = (criticals: Array<{ code: string }>, code: ErrorCode) =>
|
|
38
|
+
criticals.some((c) => c.code === code)
|
|
39
|
+
|
|
40
|
+
const PERMISSIONS_IN_BODY = [
|
|
41
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
42
|
+
'export const stripeWebhook = pikkuSessionlessFunc({',
|
|
43
|
+
' expose: true,',
|
|
44
|
+
' permissionsInBody: true,',
|
|
45
|
+
' func: async () => ({ ok: true }),',
|
|
46
|
+
'})',
|
|
47
|
+
].join('\n')
|
|
48
|
+
|
|
49
|
+
const COMPLEX_WORKFLOW = [
|
|
50
|
+
"import { pikkuWorkflowComplexFunc } from '@pikku/core/workflow'",
|
|
51
|
+
'export const cleanup = pikkuWorkflowComplexFunc(',
|
|
52
|
+
' async (_services, _input, { workflow }: any) => {',
|
|
53
|
+
' for (const id of [1, 2, 3]) {',
|
|
54
|
+
" await workflow.step('touch', { id })",
|
|
55
|
+
' }',
|
|
56
|
+
' return { ok: true }',
|
|
57
|
+
' }',
|
|
58
|
+
')',
|
|
59
|
+
].join('\n')
|
|
60
|
+
|
|
61
|
+
describe('allow: escape hatches are refused unless opted into', () => {
|
|
62
|
+
test('permissionsInBody is a critical when not allowed', async () => {
|
|
63
|
+
const { criticals } = await run(PERMISSIONS_IN_BODY)
|
|
64
|
+
assert.ok(
|
|
65
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
66
|
+
`expected PKU576, got ${JSON.stringify(criticals)}`
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('the message names the flag that would permit it', async () => {
|
|
71
|
+
const { criticals } = await run(PERMISSIONS_IN_BODY)
|
|
72
|
+
const found = criticals.find(
|
|
73
|
+
(c) => c.code === ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED
|
|
74
|
+
)
|
|
75
|
+
assert.match(found!.message, /allow: \{ permissionsInBody: true \}/)
|
|
76
|
+
assert.match(found!.message, /stripeWebhook/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('permissionsInBody is accepted once allowed', async () => {
|
|
80
|
+
const { criticals, state } = await run(PERMISSIONS_IN_BODY, {
|
|
81
|
+
permissionsInBody: true,
|
|
82
|
+
})
|
|
83
|
+
assert.equal(
|
|
84
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
85
|
+
false
|
|
86
|
+
)
|
|
87
|
+
const meta = Object.values(state.functions.meta) as any[]
|
|
88
|
+
assert.equal(
|
|
89
|
+
meta.find((m) => m.name === 'stripeWebhook')?.permissionsInBody,
|
|
90
|
+
true
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a refused function is not registered at all', async () => {
|
|
95
|
+
const { state } = await run(PERMISSIONS_IN_BODY)
|
|
96
|
+
const meta = Object.values(state.functions.meta) as any[]
|
|
97
|
+
assert.equal(
|
|
98
|
+
meta.some((m) => m.name === 'stripeWebhook'),
|
|
99
|
+
false,
|
|
100
|
+
'a refused function must not reach the meta, or the build would emit it anyway'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('complexWorkflows is a critical when not allowed', async () => {
|
|
105
|
+
const { criticals } = await run(COMPLEX_WORKFLOW)
|
|
106
|
+
assert.ok(
|
|
107
|
+
has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED),
|
|
108
|
+
`expected PKU643, got ${JSON.stringify(criticals)}`
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('the complex-workflow message points at the DSL', async () => {
|
|
113
|
+
const { criticals } = await run(COMPLEX_WORKFLOW)
|
|
114
|
+
const found = criticals.find(
|
|
115
|
+
(c) => c.code === ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED
|
|
116
|
+
)
|
|
117
|
+
assert.match(found!.message, /pikkuWorkflowFunc/)
|
|
118
|
+
assert.match(found!.message, /allow: \{ complexWorkflows: true \}/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('complexWorkflows is accepted once allowed', async () => {
|
|
122
|
+
const { criticals } = await run(COMPLEX_WORKFLOW, {
|
|
123
|
+
complexWorkflows: true,
|
|
124
|
+
})
|
|
125
|
+
assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an ordinary function and DSL workflow are unaffected', async () => {
|
|
129
|
+
const { criticals } = await run(
|
|
130
|
+
[
|
|
131
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
132
|
+
'export const ordinary = pikkuFunc({',
|
|
133
|
+
' expose: true,',
|
|
134
|
+
' func: async () => ({ ok: true }),',
|
|
135
|
+
'})',
|
|
136
|
+
].join('\n')
|
|
137
|
+
)
|
|
138
|
+
assert.equal(
|
|
139
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
140
|
+
false
|
|
141
|
+
)
|
|
142
|
+
assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
|
|
143
|
+
})
|
|
144
|
+
})
|