@pikku/core 0.12.89 → 0.12.91
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 +181 -0
- package/dist/services/http-personas.d.ts +21 -7
- package/dist/services/http-personas.js +39 -28
- package/dist/services/persona-sign-in.d.ts +107 -0
- package/dist/services/persona-sign-in.js +179 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/rpc/rpc-runner.js +4 -5
- package/dist/wirings/virtual-user/index.d.ts +3 -1
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
- package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
- package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
- package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -2
- package/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/ecosystem-tier-removed.test.ts +69 -0
- package/src/public-surface.json +10 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +60 -33
- package/src/services/persona-sign-in.test.ts +209 -0
- package/src/services/persona-sign-in.ts +284 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/rpc/rpc-runner.test.ts +100 -0
- package/src/wirings/rpc/rpc-runner.ts +8 -5
- package/src/wirings/virtual-user/index.ts +18 -0
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
- package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
- package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
- package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
- package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
- package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +3 -2
- package/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { join, dirname, relative } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
8
|
+
|
|
9
|
+
const skipped = new Set([
|
|
10
|
+
'node_modules',
|
|
11
|
+
'dist',
|
|
12
|
+
'.pikku',
|
|
13
|
+
'.next',
|
|
14
|
+
'build',
|
|
15
|
+
'.git',
|
|
16
|
+
'.deploy',
|
|
17
|
+
'coverage',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const collectSourceFiles = (
|
|
21
|
+
directory: string,
|
|
22
|
+
out: string[] = []
|
|
23
|
+
): string[] => {
|
|
24
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
if (!skipped.has(entry.name)) {
|
|
27
|
+
collectSourceFiles(join(directory, entry.name), out)
|
|
28
|
+
}
|
|
29
|
+
} else if (/\.(ts|tsx|js|mjs|mts|cts)$/.test(entry.name)) {
|
|
30
|
+
out.push(join(directory, entry.name))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A stale compiled `.test.js` next to this file would otherwise read as an
|
|
38
|
+
* offender of its own scan, which is how a sibling removal guard in this
|
|
39
|
+
* directory reports a phantom failure on a dirty tree. Comparing paths with
|
|
40
|
+
* the extension dropped excludes this file and its build artifacts without
|
|
41
|
+
* excluding a neighbour that merely shares the prefix.
|
|
42
|
+
*/
|
|
43
|
+
const withoutExtension = (file: string): string =>
|
|
44
|
+
file.replace(/\.(ts|tsx|js|mjs|mts|cts)$/, '')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `@pikku/core/ecosystem/*` tier was deleted in favour of one door per
|
|
48
|
+
* name. Nothing resolves those specifiers any more, but a dead one is easy to
|
|
49
|
+
* miss: a type-only import is erased before it can fail at runtime, and the
|
|
50
|
+
* service packages exclude `**\/*.test.ts` from their tsconfig, so neither the
|
|
51
|
+
* test run nor `yarn tsc` reports it.
|
|
52
|
+
*/
|
|
53
|
+
describe('the ecosystem entry-point tier is gone', () => {
|
|
54
|
+
test('no source file imports from @pikku/core/ecosystem', () => {
|
|
55
|
+
const self = withoutExtension(fileURLToPath(import.meta.url))
|
|
56
|
+
const offenders = collectSourceFiles(repoRoot)
|
|
57
|
+
.filter(
|
|
58
|
+
(file) =>
|
|
59
|
+
withoutExtension(file) !== self &&
|
|
60
|
+
/@pikku\/core\/ecosystem/.test(readFileSync(file, 'utf-8'))
|
|
61
|
+
)
|
|
62
|
+
.map((file) => relative(repoRoot, file))
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
offenders,
|
|
65
|
+
[],
|
|
66
|
+
`@pikku/core/ecosystem imports found in:\n${offenders.join('\n')}`
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/public-surface.json
CHANGED
|
@@ -97,21 +97,27 @@
|
|
|
97
97
|
"./workflow/types": [],
|
|
98
98
|
"./actor-flow": ["runConversation"],
|
|
99
99
|
"./virtual-user": [
|
|
100
|
+
"DEFAULT_MAX_INTERVAL_MS",
|
|
101
|
+
"DEFAULT_MIN_INTERVAL_MS",
|
|
100
102
|
"DISPOSITIONS",
|
|
101
103
|
"IntentStack",
|
|
102
104
|
"PRODUCTION_DISPOSITION",
|
|
105
|
+
"STALE_RUN_AFTER_MS",
|
|
103
106
|
"catalogueClassification",
|
|
104
107
|
"catalogueLookup",
|
|
105
108
|
"deriveCatalogue",
|
|
106
109
|
"deriveIntents",
|
|
107
110
|
"dispositionProfile",
|
|
108
111
|
"intentsForPersona",
|
|
112
|
+
"isDue",
|
|
109
113
|
"isReadOnly",
|
|
114
|
+
"nextRunAt",
|
|
110
115
|
"personaScopes",
|
|
111
116
|
"personaVirtualUserTarget",
|
|
112
117
|
"prepareVirtualUserRun",
|
|
113
118
|
"reachableCatalogue",
|
|
114
119
|
"runVirtualUser",
|
|
120
|
+
"tickVirtualUserSchedules",
|
|
115
121
|
"unreachableCatalogue"
|
|
116
122
|
],
|
|
117
123
|
"./channel/local": [
|
|
@@ -266,9 +272,13 @@
|
|
|
266
272
|
"validateAndBuildSystemRoleDefinitionsMeta"
|
|
267
273
|
],
|
|
268
274
|
"./persona": [
|
|
275
|
+
"ActorSignIn",
|
|
269
276
|
"HttpPersona",
|
|
277
|
+
"IMPERSONATE_USER_ID_HEADER",
|
|
278
|
+
"OperatorSignIn",
|
|
270
279
|
"createHttpPersonas",
|
|
271
280
|
"definePersonas",
|
|
281
|
+
"establishOperatorSession",
|
|
272
282
|
"isRunnablePersona",
|
|
273
283
|
"personaEmail",
|
|
274
284
|
"personaEmails",
|
|
@@ -17,6 +17,7 @@ const startAgentTarget = async () => {
|
|
|
17
17
|
let logins = 0
|
|
18
18
|
let authRequired = false
|
|
19
19
|
let approvalsSeen: unknown[] = []
|
|
20
|
+
let firstAgentRequestAuthed: boolean | null = null
|
|
20
21
|
const server: Server = createServer((req, res) => {
|
|
21
22
|
const chunks: Buffer[] = []
|
|
22
23
|
req.on('data', (c) => chunks.push(c))
|
|
@@ -37,6 +38,11 @@ const startAgentTarget = async () => {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const isAgentRoute = req.url?.startsWith('/api/rpc/agent/')
|
|
41
|
+
if (isAgentRoute && firstAgentRequestAuthed === null) {
|
|
42
|
+
firstAgentRequestAuthed = (req.headers.cookie ?? '').includes(
|
|
43
|
+
'session='
|
|
44
|
+
)
|
|
45
|
+
}
|
|
40
46
|
if (
|
|
41
47
|
isAgentRoute &&
|
|
42
48
|
authRequired &&
|
|
@@ -85,10 +91,13 @@ const startAgentTarget = async () => {
|
|
|
85
91
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
86
92
|
loginCount: () => logins,
|
|
87
93
|
approvalsSeen: () => approvalsSeen,
|
|
94
|
+
/** Whether the very first agent call carried a session, not merely that one was minted. */
|
|
95
|
+
firstAgentRequestAuthed: () => firstAgentRequestAuthed,
|
|
88
96
|
reset: (opts?: { authRequired?: boolean }) => {
|
|
89
97
|
agentRuns = 0
|
|
90
98
|
logins = 0
|
|
91
99
|
approvalsSeen = []
|
|
100
|
+
firstAgentRequestAuthed = null
|
|
92
101
|
authRequired = opts?.authRequired ?? false
|
|
93
102
|
},
|
|
94
103
|
}
|
|
@@ -180,8 +189,13 @@ describe('HttpPersona.converse', async () => {
|
|
|
180
189
|
assert.deepEqual(target.approvalsSeen(), [
|
|
181
190
|
[{ toolCallId: 'tc1', approved: true }],
|
|
182
191
|
])
|
|
183
|
-
//
|
|
184
|
-
|
|
192
|
+
// Signed in even though the agent route is public: a thread minted under a
|
|
193
|
+
// fresh anonymous id per request belongs to nobody, so turn two comes back
|
|
194
|
+
// as somebody else's. A persona is a real account either way — and it is
|
|
195
|
+
// the *first* call that has to carry the session, which a login count
|
|
196
|
+
// alone would not show.
|
|
197
|
+
assert.equal(target.loginCount(), 1)
|
|
198
|
+
assert.equal(target.firstAgentRequestAuthed(), true)
|
|
185
199
|
})
|
|
186
200
|
|
|
187
201
|
test('signs in lazily and retries once when an agent route returns 401', async () => {
|
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
createCookieJar,
|
|
17
17
|
type ScenarioCookieJar,
|
|
18
18
|
} from '../wirings/workflow/scenario-cookie-jar.js'
|
|
19
|
+
import {
|
|
20
|
+
ActorSignIn,
|
|
21
|
+
OperatorSignIn,
|
|
22
|
+
type OperatorSignInOptions,
|
|
23
|
+
type PersonaSignIn,
|
|
24
|
+
} from './persona-sign-in.js'
|
|
19
25
|
import { getSingletonServices } from '../pikku-state.js'
|
|
20
26
|
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
21
27
|
|
|
@@ -30,8 +36,19 @@ export interface HttpPersonasConfig {
|
|
|
30
36
|
/**
|
|
31
37
|
* The impersonation secret. Sign-in only ever works for user rows flagged
|
|
32
38
|
* `actor: true` — knowing the secret never impersonates real users.
|
|
39
|
+
*
|
|
40
|
+
* The local-development credential. A deployed stage has none, and passes
|
|
41
|
+
* {@link HttpPersonasConfig.operator} instead.
|
|
42
|
+
*/
|
|
43
|
+
secret?: string
|
|
44
|
+
/**
|
|
45
|
+
* Fabric operator credentials, for signing personas into a DEPLOYED stage.
|
|
46
|
+
*
|
|
47
|
+
* Mutually exclusive with {@link HttpPersonasConfig.secret}: the operator
|
|
48
|
+
* path acts as the persona through an admin session rather than logging in as
|
|
49
|
+
* them, so no test credential has to exist on the target at all.
|
|
33
50
|
*/
|
|
34
|
-
|
|
51
|
+
operator?: OperatorSignInOptions
|
|
35
52
|
/** Persona id → the declaration with its address filled in. */
|
|
36
53
|
personas: Record<string, ResolvedPersona>
|
|
37
54
|
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
@@ -49,12 +66,13 @@ export interface HttpPersonasConfig {
|
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
52
|
-
* Default HTTP-backed persona. Signs in lazily on first invoke
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* Default HTTP-backed persona. Signs in lazily on first invoke, holds the
|
|
70
|
+
* session cookies for its lifetime, and re-logs-in once on a 401 mid-run (long
|
|
71
|
+
* health-check runs can outlive a session).
|
|
72
|
+
*
|
|
73
|
+
* How it signs in depends on the target, and the two ways are not
|
|
74
|
+
* interchangeable — see {@link ActorSignIn} for local development and
|
|
75
|
+
* {@link OperatorSignIn} for a deployed stage.
|
|
58
76
|
*/
|
|
59
77
|
export class HttpPersona implements ScenarioPersona {
|
|
60
78
|
private jar: ScenarioCookieJar
|
|
@@ -65,6 +83,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
65
83
|
* established.
|
|
66
84
|
*/
|
|
67
85
|
private signedIn = false
|
|
86
|
+
private signIn: PersonaSignIn
|
|
68
87
|
|
|
69
88
|
constructor(
|
|
70
89
|
readonly name: string,
|
|
@@ -72,6 +91,19 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
72
91
|
private config: HttpPersonasConfig
|
|
73
92
|
) {
|
|
74
93
|
this.jar = createCookieJar(config.apiUrl)
|
|
94
|
+
if (config.operator) {
|
|
95
|
+
this.signIn = new OperatorSignIn(config.apiUrl, config.operator)
|
|
96
|
+
} else if (config.secret) {
|
|
97
|
+
this.signIn = new ActorSignIn(
|
|
98
|
+
config.apiUrl,
|
|
99
|
+
config.secret,
|
|
100
|
+
config.signInPath ?? '/auth/sign-in/actor'
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`[scenario] persona '${name}' has no way to sign in — set 'secret' for a dev target or 'operator' for a deployed one`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
get email(): string {
|
|
@@ -111,6 +143,14 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
111
143
|
if (!agentRunner) {
|
|
112
144
|
throw new AIProviderNotConfiguredError()
|
|
113
145
|
}
|
|
146
|
+
// Signed in here rather than left to postAgent's 401 retry, which a public
|
|
147
|
+
// agent route never triggers. An unowned thread is minted under a fresh
|
|
148
|
+
// anonymous id per request, so turn one succeeds and turn two is refused as
|
|
149
|
+
// somebody else's — and a persona is a real account with real credentials,
|
|
150
|
+
// so there is no case where conversing as nobody is the intent.
|
|
151
|
+
if (!this.signedIn) {
|
|
152
|
+
await this.login()
|
|
153
|
+
}
|
|
114
154
|
const model = options.model ?? this.config.model
|
|
115
155
|
if (!model) {
|
|
116
156
|
throw new Error(
|
|
@@ -153,7 +193,9 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
153
193
|
await this.login()
|
|
154
194
|
}
|
|
155
195
|
const sessionPath = this.config.sessionPath ?? '/auth/get-session'
|
|
156
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}
|
|
196
|
+
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
|
|
197
|
+
headers: this.signIn.headers(),
|
|
198
|
+
})
|
|
157
199
|
if (!res.ok) {
|
|
158
200
|
return null
|
|
159
201
|
}
|
|
@@ -218,7 +260,10 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
218
260
|
const send = () =>
|
|
219
261
|
this.jar.fetch(url, {
|
|
220
262
|
method: 'POST',
|
|
221
|
-
headers: {
|
|
263
|
+
headers: {
|
|
264
|
+
'content-type': 'application/json',
|
|
265
|
+
...this.signIn.headers(),
|
|
266
|
+
},
|
|
222
267
|
body: JSON.stringify(body),
|
|
223
268
|
})
|
|
224
269
|
|
|
@@ -247,7 +292,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
247
292
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
248
293
|
return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
249
294
|
method: 'POST',
|
|
250
|
-
headers: {
|
|
295
|
+
headers: {
|
|
296
|
+
'content-type': 'application/json',
|
|
297
|
+
...this.signIn.headers(),
|
|
298
|
+
...extraHeaders,
|
|
299
|
+
},
|
|
251
300
|
body: JSON.stringify({ data }),
|
|
252
301
|
})
|
|
253
302
|
}
|
|
@@ -259,29 +308,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
259
308
|
}
|
|
260
309
|
|
|
261
310
|
private async login(): Promise<void> {
|
|
262
|
-
|
|
263
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
264
|
-
method: 'POST',
|
|
265
|
-
headers: { 'content-type': 'application/json' },
|
|
266
|
-
body: JSON.stringify({
|
|
267
|
-
email: this.persona.email,
|
|
268
|
-
name: this.persona.name,
|
|
269
|
-
secret: this.config.secret,
|
|
270
|
-
}),
|
|
271
|
-
})
|
|
272
|
-
if (!res.ok) {
|
|
273
|
-
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
274
|
-
throw new Error(
|
|
275
|
-
`[scenario] persona sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
// What proves a session was established is this response setting a cookie,
|
|
279
|
-
// not the jar being non-empty — the target may have set one earlier.
|
|
280
|
-
if (res.headers.getSetCookie().length === 0) {
|
|
281
|
-
throw new Error(
|
|
282
|
-
`[scenario] persona sign-in for '${this.name}' returned no session cookie`
|
|
283
|
-
)
|
|
284
|
-
}
|
|
311
|
+
await this.signIn.login(this.jar, this.persona)
|
|
285
312
|
this.signedIn = true
|
|
286
313
|
}
|
|
287
314
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createServer, type Server } from 'node:http'
|
|
4
|
+
|
|
5
|
+
import { createHttpPersonas } from './http-personas.js'
|
|
6
|
+
import { establishOperatorSession } from './persona-sign-in.js'
|
|
7
|
+
|
|
8
|
+
const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal deployed stage: the fabric plugin's sign-in, the admin plugin's
|
|
12
|
+
* user endpoints, and an RPC route that reports back who it was addressed as.
|
|
13
|
+
*/
|
|
14
|
+
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
15
|
+
const users = [...seeded]
|
|
16
|
+
let created = 0
|
|
17
|
+
const server: Server = createServer((req, res) => {
|
|
18
|
+
const chunks: Buffer[] = []
|
|
19
|
+
req.on('data', (c) => chunks.push(c))
|
|
20
|
+
req.on('end', () => {
|
|
21
|
+
const body = chunks.length
|
|
22
|
+
? JSON.parse(Buffer.concat(chunks).toString())
|
|
23
|
+
: {}
|
|
24
|
+
const url = new URL(req.url ?? '/', 'http://stage.invalid')
|
|
25
|
+
|
|
26
|
+
if (url.pathname === '/api/auth/sign-in/fabric') {
|
|
27
|
+
if (body.token !== OPERATOR_TOKEN) {
|
|
28
|
+
res.writeHead(401).end(JSON.stringify({ message: 'bad token' }))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
res.setHeader('set-cookie', ['session=operator; Path=/; HttpOnly'])
|
|
32
|
+
res.writeHead(200).end(JSON.stringify({ token: 'operator' }))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The admin plugin refuses an unauthenticated caller, and saying so here
|
|
37
|
+
// is the point: a handshake that dropped the operator session between
|
|
38
|
+
// sign-in and lookup would otherwise pass this stage happily.
|
|
39
|
+
if (url.pathname.startsWith('/api/auth/admin/')) {
|
|
40
|
+
if (!/(^|;\s*)session=operator(;|$)/.test(req.headers.cookie ?? '')) {
|
|
41
|
+
res.writeHead(401).end(JSON.stringify({ message: 'no session' }))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (url.pathname === '/api/auth/admin/list-users') {
|
|
47
|
+
const wanted = url.searchParams.get('filterValue')
|
|
48
|
+
res
|
|
49
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
50
|
+
.end(
|
|
51
|
+
JSON.stringify({ users: users.filter((u) => u.email === wanted) })
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (url.pathname === '/api/auth/admin/create-user') {
|
|
57
|
+
created++
|
|
58
|
+
const user = { id: `made-${created}`, email: body.email }
|
|
59
|
+
users.push(user)
|
|
60
|
+
res
|
|
61
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
62
|
+
.end(JSON.stringify({ user, sawPassword: Boolean(body.password) }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (url.pathname.startsWith('/api/rpc/')) {
|
|
67
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
actingAs: req.headers['x-pikku-impersonate-user-id'] ?? null,
|
|
70
|
+
cookie: req.headers.cookie ?? '',
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(404).end()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
await new Promise<void>((resolve) => server.listen(0, resolve))
|
|
80
|
+
const port = (server.address() as { port: number }).port
|
|
81
|
+
return {
|
|
82
|
+
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
83
|
+
server,
|
|
84
|
+
get createdCount() {
|
|
85
|
+
return created
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const persona = (email: string) => ({
|
|
91
|
+
id: 'customer',
|
|
92
|
+
name: 'Customer',
|
|
93
|
+
email,
|
|
94
|
+
roles: ['client'],
|
|
95
|
+
goals: [],
|
|
96
|
+
tags: [],
|
|
97
|
+
runnable: true,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('operator persona sign-in', () => {
|
|
101
|
+
const servers: Server[] = []
|
|
102
|
+
after(() => servers.forEach((s) => s.close()))
|
|
103
|
+
|
|
104
|
+
test('acts as an existing account rather than signing in as it', async () => {
|
|
105
|
+
const stage = await startStage([
|
|
106
|
+
{ id: 'user-7', email: 'customer@personas.invalid' },
|
|
107
|
+
])
|
|
108
|
+
servers.push(stage.server)
|
|
109
|
+
|
|
110
|
+
const personas = createHttpPersonas({
|
|
111
|
+
apiUrl: stage.apiUrl,
|
|
112
|
+
operator: { token: OPERATOR_TOKEN },
|
|
113
|
+
personas: { customer: persona('customer@personas.invalid') },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const result = (await personas.customer!.invoke('whoami', {})) as {
|
|
117
|
+
actingAs: string | null
|
|
118
|
+
cookie: string
|
|
119
|
+
}
|
|
120
|
+
assert.equal(result.actingAs, 'user-7')
|
|
121
|
+
assert.match(result.cookie, /session=operator/)
|
|
122
|
+
assert.equal(stage.createdCount, 0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('refuses a persona the stage has no account for', async () => {
|
|
126
|
+
const stage = await startStage([])
|
|
127
|
+
servers.push(stage.server)
|
|
128
|
+
|
|
129
|
+
const personas = createHttpPersonas({
|
|
130
|
+
apiUrl: stage.apiUrl,
|
|
131
|
+
operator: { token: OPERATOR_TOKEN },
|
|
132
|
+
personas: { customer: persona('ghost@personas.invalid') },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => personas.customer!.invoke('whoami', {}),
|
|
137
|
+
/no account on the target for persona 'customer'/
|
|
138
|
+
)
|
|
139
|
+
assert.equal(stage.createdCount, 0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('provisions the account only when told to', async () => {
|
|
143
|
+
const stage = await startStage([])
|
|
144
|
+
servers.push(stage.server)
|
|
145
|
+
|
|
146
|
+
const personas = createHttpPersonas({
|
|
147
|
+
apiUrl: stage.apiUrl,
|
|
148
|
+
operator: { token: OPERATOR_TOKEN, createMissing: true },
|
|
149
|
+
personas: { customer: persona('fresh@personas.invalid') },
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const result = (await personas.customer!.invoke('whoami', {})) as {
|
|
153
|
+
actingAs: string | null
|
|
154
|
+
}
|
|
155
|
+
assert.equal(result.actingAs, 'made-1')
|
|
156
|
+
assert.equal(stage.createdCount, 1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('mints the operator session from a token factory', async () => {
|
|
160
|
+
const stage = await startStage([
|
|
161
|
+
{ id: 'user-9', email: 'lazy@personas.invalid' },
|
|
162
|
+
])
|
|
163
|
+
servers.push(stage.server)
|
|
164
|
+
|
|
165
|
+
let minted = 0
|
|
166
|
+
const personas = createHttpPersonas({
|
|
167
|
+
apiUrl: stage.apiUrl,
|
|
168
|
+
operator: {
|
|
169
|
+
token: () => {
|
|
170
|
+
minted++
|
|
171
|
+
return OPERATOR_TOKEN
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
personas: { customer: persona('lazy@personas.invalid') },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await personas.customer!.invoke('whoami', {})
|
|
178
|
+
assert.equal(minted, 1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('carries the operator session into the admin lookup', async () => {
|
|
182
|
+
const stage = await startStage([{ id: 'user-9', email: 'susan@acme.test' }])
|
|
183
|
+
servers.push(stage.server)
|
|
184
|
+
|
|
185
|
+
// Plain fetch keeps no cookies, which is the browser provider's situation:
|
|
186
|
+
// it plants them on a Playwright context only once this has returned.
|
|
187
|
+
const { userId } = await establishOperatorSession(
|
|
188
|
+
fetch,
|
|
189
|
+
stage.apiUrl,
|
|
190
|
+
persona('susan@acme.test'),
|
|
191
|
+
{ token: OPERATOR_TOKEN }
|
|
192
|
+
)
|
|
193
|
+
assert.equal(userId, 'user-9')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('a persona with neither credential is refused at construction', async () => {
|
|
197
|
+
const stage = await startStage([])
|
|
198
|
+
servers.push(stage.server)
|
|
199
|
+
|
|
200
|
+
assert.throws(
|
|
201
|
+
() =>
|
|
202
|
+
createHttpPersonas({
|
|
203
|
+
apiUrl: stage.apiUrl,
|
|
204
|
+
personas: { customer: persona('nobody@personas.invalid') },
|
|
205
|
+
}),
|
|
206
|
+
/has no way to sign in/
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
})
|