spaps 0.7.6 → 0.7.8
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/AI_TOOLS.json +5566 -38
- package/README.md +67 -13
- package/assets/local-runtime/Dockerfile +13 -0
- package/assets/local-runtime/docker-compose.yml +2 -1
- package/assets/local-runtime/manifest.json +3 -1
- package/bin/spaps.js +34 -8
- package/package.json +3 -4
- package/src/ai-helper.js +44 -10
- package/src/ai-tool-spec.js +19 -4
- package/src/auth/env.js +5 -0
- package/src/cli-dispatcher.js +365 -91
- package/src/docs-quick.js +37 -0
- package/src/docs-system.js +1 -31
- package/src/doctor.js +58 -1
- package/src/domain-cli.js +79 -0
- package/src/domains.js +193 -0
- package/src/fixture-kernel.js +898 -29
- package/src/handlers.js +535 -29
- package/src/help-quick.js +42 -0
- package/src/help-system.js +1 -36
- package/src/home-view.js +200 -0
- package/src/local-runtime.js +19 -4
- package/src/local-server.js +30 -1
package/src/fixture-kernel.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
2
|
const path = require('node:path');
|
|
3
|
+
const axios = require('axios');
|
|
3
4
|
|
|
4
5
|
const { DEFAULT_PORT } = require('./config');
|
|
5
6
|
const { buildBaseUrl, getServerRuntime } = require('./local-runtime');
|
|
@@ -27,7 +28,11 @@ const COMPAT_STORAGE_KEYS = {
|
|
|
27
28
|
legacy_user: 'spaps_user',
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
function cloneValue(value) {
|
|
32
|
+
return JSON.parse(JSON.stringify(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BASE_PERSONAS = [
|
|
31
36
|
{
|
|
32
37
|
code: 'user',
|
|
33
38
|
display_name: 'Local User',
|
|
@@ -95,16 +100,521 @@ const DEFAULT_PERSONAS = [
|
|
|
95
100
|
},
|
|
96
101
|
];
|
|
97
102
|
|
|
103
|
+
function getBasePersona(code) {
|
|
104
|
+
const persona = BASE_PERSONAS.find((entry) => entry.code === code);
|
|
105
|
+
if (!persona) {
|
|
106
|
+
throw new Error(`Unknown base persona "${code}"`);
|
|
107
|
+
}
|
|
108
|
+
return persona;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeDerivedPersona(code, displayName, fromCode, overrides = {}) {
|
|
112
|
+
const base = getBasePersona(fromCode);
|
|
113
|
+
const {
|
|
114
|
+
selector,
|
|
115
|
+
profile,
|
|
116
|
+
permissions,
|
|
117
|
+
browser,
|
|
118
|
+
...rest
|
|
119
|
+
} = overrides;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...cloneValue(base),
|
|
123
|
+
...cloneValue(rest),
|
|
124
|
+
code,
|
|
125
|
+
display_name: displayName,
|
|
126
|
+
selector: selector ? cloneValue(selector) : cloneValue(base.selector),
|
|
127
|
+
profile: {
|
|
128
|
+
...cloneValue(base.profile),
|
|
129
|
+
...(profile ? cloneValue(profile) : {}),
|
|
130
|
+
},
|
|
131
|
+
permissions: permissions ? [...permissions] : [...(base.permissions || [])],
|
|
132
|
+
browser: {
|
|
133
|
+
local_storage: {
|
|
134
|
+
...(base.browser?.local_storage || {}),
|
|
135
|
+
...((browser && browser.local_storage) || {}),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const DEFAULT_PERSONAS = [
|
|
142
|
+
...BASE_PERSONAS,
|
|
143
|
+
makeDerivedPersona('dayrate-guest', 'Dayrate Guest', 'user', {
|
|
144
|
+
profile: {
|
|
145
|
+
username: 'dayrate-guest',
|
|
146
|
+
},
|
|
147
|
+
scenario: {
|
|
148
|
+
dayrate: {
|
|
149
|
+
mode: 'paid',
|
|
150
|
+
policy_key: null,
|
|
151
|
+
sample_request: {
|
|
152
|
+
date: '2026-05-01',
|
|
153
|
+
slot: 'AM',
|
|
154
|
+
clientEmail: 'guest-fixture@example.com',
|
|
155
|
+
clientName: 'Fixture Guest',
|
|
156
|
+
successUrl: 'https://example.com/dayrate/success',
|
|
157
|
+
cancelUrl: 'https://example.com/dayrate/cancel',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
makeDerivedPersona('dayrate-entitled', 'Dayrate Entitled', 'user', {
|
|
163
|
+
permissions: ['view_products', 'book_dayrate_free'],
|
|
164
|
+
profile: {
|
|
165
|
+
username: 'dayrate-entitled',
|
|
166
|
+
},
|
|
167
|
+
scenario: {
|
|
168
|
+
dayrate: {
|
|
169
|
+
mode: 'free',
|
|
170
|
+
policy_key: 'fixture-dayrate-free-{{global.run_id}}',
|
|
171
|
+
entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
|
|
172
|
+
free_bookings_remaining: 2,
|
|
173
|
+
sample_request: {
|
|
174
|
+
date: '2026-05-02',
|
|
175
|
+
slot: 'AM',
|
|
176
|
+
clientEmail: 'entitled-fixture@example.com',
|
|
177
|
+
clientName: 'Fixture Entitled',
|
|
178
|
+
successUrl: 'https://example.com/dayrate/success',
|
|
179
|
+
cancelUrl: 'https://example.com/dayrate/cancel',
|
|
180
|
+
policyKey: 'fixture-dayrate-free-{{global.run_id}}',
|
|
181
|
+
userId: '{{persona.profile.user_id}}',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
seed: [
|
|
186
|
+
{
|
|
187
|
+
as: 'admin',
|
|
188
|
+
method: 'PUT',
|
|
189
|
+
path: '/api/dayrate/admin/config',
|
|
190
|
+
body: {
|
|
191
|
+
base_rate: 12000,
|
|
192
|
+
available_days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
|
|
193
|
+
horizon_weeks: 8,
|
|
194
|
+
min_notice_hours: 1,
|
|
195
|
+
hold_duration_minutes: 15,
|
|
196
|
+
timezone: 'America/Toronto',
|
|
197
|
+
currency: 'usd',
|
|
198
|
+
product_name: 'Fixture Dayrate',
|
|
199
|
+
slot_definitions: {
|
|
200
|
+
AM: { start: '09:00', end: '12:00' },
|
|
201
|
+
PM: { start: '13:00', end: '16:00' },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
as: 'admin',
|
|
207
|
+
method: 'POST',
|
|
208
|
+
path: '/api/dayrate/admin/policies',
|
|
209
|
+
expected_statuses: [201, 409],
|
|
210
|
+
body: {
|
|
211
|
+
policyKey: 'fixture-dayrate-free-{{global.run_id}}',
|
|
212
|
+
entitlementKey: 'fixture.dayrate.free.{{global.run_id}}',
|
|
213
|
+
freeBookingsPerPeriod: 2,
|
|
214
|
+
periodDays: 28,
|
|
215
|
+
overageRate: null,
|
|
216
|
+
overageCurrency: 'usd',
|
|
217
|
+
overageProductName: 'Fixture Dayrate Overage',
|
|
218
|
+
rescheduleIsFree: true,
|
|
219
|
+
metadata: {
|
|
220
|
+
source: 'spaps-fixture-seed',
|
|
221
|
+
persona: 'dayrate-entitled',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
as: 'admin',
|
|
227
|
+
method: 'POST',
|
|
228
|
+
path: '/api/entitlements/manual',
|
|
229
|
+
expected_statuses: [201, 409],
|
|
230
|
+
body: {
|
|
231
|
+
beneficiary_user_id: '{{persona.profile.user_id}}',
|
|
232
|
+
beneficiary_email: '{{persona.profile.email}}',
|
|
233
|
+
entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
|
|
234
|
+
resource_type: 'user',
|
|
235
|
+
reason: 'spaps fixture seed',
|
|
236
|
+
metadata: {
|
|
237
|
+
source: 'spaps-fixture-seed',
|
|
238
|
+
persona: 'dayrate-entitled',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
}),
|
|
244
|
+
makeDerivedPersona('dayrate-overage', 'Dayrate Overage', 'premium', {
|
|
245
|
+
permissions: ['view_products', 'book_dayrate_free'],
|
|
246
|
+
profile: {
|
|
247
|
+
username: 'dayrate-overage',
|
|
248
|
+
},
|
|
249
|
+
scenario: {
|
|
250
|
+
dayrate: {
|
|
251
|
+
mode: 'overage',
|
|
252
|
+
policy_key: 'fixture-dayrate-overage-{{global.run_id}}',
|
|
253
|
+
entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
|
|
254
|
+
overage_rate: 1500,
|
|
255
|
+
free_bookings_remaining: 0,
|
|
256
|
+
sample_request: {
|
|
257
|
+
date: '2026-05-03',
|
|
258
|
+
slot: 'PM',
|
|
259
|
+
clientEmail: 'overage-fixture@example.com',
|
|
260
|
+
clientName: 'Fixture Overage',
|
|
261
|
+
successUrl: 'https://example.com/dayrate/success',
|
|
262
|
+
cancelUrl: 'https://example.com/dayrate/cancel',
|
|
263
|
+
policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
|
|
264
|
+
userId: '{{persona.profile.user_id}}',
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
seed: [
|
|
269
|
+
{
|
|
270
|
+
as: 'admin',
|
|
271
|
+
method: 'POST',
|
|
272
|
+
path: '/api/dayrate/admin/policies',
|
|
273
|
+
expected_statuses: [201, 409],
|
|
274
|
+
body: {
|
|
275
|
+
policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
|
|
276
|
+
entitlementKey: 'fixture.dayrate.overage.{{global.run_id}}',
|
|
277
|
+
freeBookingsPerPeriod: 0,
|
|
278
|
+
periodDays: 28,
|
|
279
|
+
overageRate: 1500,
|
|
280
|
+
overageCurrency: 'usd',
|
|
281
|
+
overageProductName: 'Fixture Dayrate Overage',
|
|
282
|
+
rescheduleIsFree: true,
|
|
283
|
+
metadata: {
|
|
284
|
+
source: 'spaps-fixture-seed',
|
|
285
|
+
persona: 'dayrate-overage',
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
as: 'admin',
|
|
291
|
+
method: 'POST',
|
|
292
|
+
path: '/api/entitlements/manual',
|
|
293
|
+
expected_statuses: [201, 409],
|
|
294
|
+
body: {
|
|
295
|
+
beneficiary_user_id: '{{persona.profile.user_id}}',
|
|
296
|
+
beneficiary_email: '{{persona.profile.email}}',
|
|
297
|
+
entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
|
|
298
|
+
resource_type: 'user',
|
|
299
|
+
reason: 'spaps fixture seed',
|
|
300
|
+
metadata: {
|
|
301
|
+
source: 'spaps-fixture-seed',
|
|
302
|
+
persona: 'dayrate-overage',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
}),
|
|
308
|
+
makeDerivedPersona('email-local-safe', 'Email Local Safe', 'user', {
|
|
309
|
+
profile: {
|
|
310
|
+
username: 'email-local-safe',
|
|
311
|
+
},
|
|
312
|
+
scenario: {
|
|
313
|
+
email: {
|
|
314
|
+
template_key: 'fixture-email-safe-{{global.run_id}}',
|
|
315
|
+
send_request: {
|
|
316
|
+
template_key: 'fixture-email-safe-{{global.run_id}}',
|
|
317
|
+
to: 'recipient@example.com',
|
|
318
|
+
context: {
|
|
319
|
+
name: 'Fixture Recipient',
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
expected_status: 'short_circuited',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
seed: [
|
|
326
|
+
{
|
|
327
|
+
as: 'admin',
|
|
328
|
+
method: 'POST',
|
|
329
|
+
path: '/api/email/templates',
|
|
330
|
+
expected_statuses: [201, 409],
|
|
331
|
+
body: {
|
|
332
|
+
name: 'Fixture Local Safe Email',
|
|
333
|
+
template_key: 'fixture-email-safe-{{global.run_id}}',
|
|
334
|
+
subject: 'Hello {{name}}',
|
|
335
|
+
html_body: '<p>Hello {{name}}</p>',
|
|
336
|
+
text_body: 'Hello {{name}}',
|
|
337
|
+
from_email: 'noreply@example.com',
|
|
338
|
+
category: 'fixture',
|
|
339
|
+
sample_context: {
|
|
340
|
+
name: 'Fixture Recipient',
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
}),
|
|
346
|
+
makeDerivedPersona('webhook-signature-invalid', 'Webhook Signature Invalid', 'admin', {
|
|
347
|
+
profile: {
|
|
348
|
+
username: 'webhook-signature-invalid',
|
|
349
|
+
},
|
|
350
|
+
scenario: {
|
|
351
|
+
webhooks: {
|
|
352
|
+
invalid_request: {
|
|
353
|
+
path: '/api/webhooks/mailgun/events',
|
|
354
|
+
expected_status: 401,
|
|
355
|
+
body: {
|
|
356
|
+
signature: {
|
|
357
|
+
timestamp: '{{global.epoch}}',
|
|
358
|
+
token: 'fixture-invalid-token',
|
|
359
|
+
signature: 'not-a-real-signature',
|
|
360
|
+
},
|
|
361
|
+
'event-data': {
|
|
362
|
+
event: 'delivered',
|
|
363
|
+
timestamp: '2026-05-04T09:00:00Z',
|
|
364
|
+
message: {
|
|
365
|
+
headers: {
|
|
366
|
+
'message-id': 'fixture-invalid-msg',
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
makeDerivedPersona('webhook-replay', 'Webhook Replay', 'admin', {
|
|
376
|
+
profile: {
|
|
377
|
+
username: 'webhook-replay',
|
|
378
|
+
},
|
|
379
|
+
scenario: {
|
|
380
|
+
webhooks: {
|
|
381
|
+
replay_request: {
|
|
382
|
+
path: '/api/webhooks/mailgun/events',
|
|
383
|
+
expected_status: 409,
|
|
384
|
+
signing_key: 'test-key',
|
|
385
|
+
token: 'fixture-replay-token',
|
|
386
|
+
body_template: {
|
|
387
|
+
signature: {
|
|
388
|
+
timestamp: '{{global.epoch}}',
|
|
389
|
+
token: 'fixture-replay-token',
|
|
390
|
+
signature: '{{scenario_signature}}',
|
|
391
|
+
},
|
|
392
|
+
'event-data': {
|
|
393
|
+
event: 'delivered',
|
|
394
|
+
timestamp: '2026-05-04T09:05:00Z',
|
|
395
|
+
message: {
|
|
396
|
+
headers: {
|
|
397
|
+
'message-id': 'fixture-replay-msg',
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
makeDerivedPersona('policy-admin-allow', 'Policy Admin Allow', 'admin', {
|
|
407
|
+
scenario: {
|
|
408
|
+
policies: {
|
|
409
|
+
policy_name: 'fixture-admin-read-{{global.run_id}}',
|
|
410
|
+
authorize_request: {
|
|
411
|
+
policy_name: 'fixture-admin-read-{{global.run_id}}',
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
seed: [
|
|
416
|
+
{
|
|
417
|
+
as: 'admin',
|
|
418
|
+
method: 'POST',
|
|
419
|
+
path: '/api/policies',
|
|
420
|
+
expected_statuses: [201, 409],
|
|
421
|
+
body: {
|
|
422
|
+
name: 'fixture-admin-read-{{global.run_id}}',
|
|
423
|
+
description: 'Allow admin-only fixture policy',
|
|
424
|
+
effect: 'allow',
|
|
425
|
+
conditions: {
|
|
426
|
+
has_role: {
|
|
427
|
+
role: 'admin',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
priority: 100,
|
|
431
|
+
metadata: {
|
|
432
|
+
source: 'spaps-fixture-seed',
|
|
433
|
+
persona: 'policy-admin-allow',
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
}),
|
|
439
|
+
makeDerivedPersona('policy-denied-no-wallet', 'Policy Denied No Wallet', 'user', {
|
|
440
|
+
permissions: ['view_products'],
|
|
441
|
+
scenario: {
|
|
442
|
+
policies: {
|
|
443
|
+
policy_name: 'fixture-requires-wallet-{{global.run_id}}',
|
|
444
|
+
expected_decision: 'deny',
|
|
445
|
+
authorize_request: {
|
|
446
|
+
policy_name: 'fixture-requires-wallet-{{global.run_id}}',
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
seed: [
|
|
451
|
+
{
|
|
452
|
+
as: 'admin',
|
|
453
|
+
method: 'POST',
|
|
454
|
+
path: '/api/policies',
|
|
455
|
+
expected_statuses: [201, 409],
|
|
456
|
+
body: {
|
|
457
|
+
name: 'fixture-requires-wallet-{{global.run_id}}',
|
|
458
|
+
description: 'Require a Solana wallet for access',
|
|
459
|
+
effect: 'allow',
|
|
460
|
+
conditions: {
|
|
461
|
+
has_wallet: {
|
|
462
|
+
chain: 'solana',
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
priority: 100,
|
|
466
|
+
metadata: {
|
|
467
|
+
source: 'spaps-fixture-seed',
|
|
468
|
+
persona: 'policy-denied-no-wallet',
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
}),
|
|
474
|
+
makeDerivedPersona('issue-reporter', 'Issue Reporter', 'user', {
|
|
475
|
+
permissions: ['view_products'],
|
|
476
|
+
scenario: {
|
|
477
|
+
issue_reporting: {
|
|
478
|
+
expected_status: 201,
|
|
479
|
+
create_request: {
|
|
480
|
+
target: {
|
|
481
|
+
component_key: 'feedback-button',
|
|
482
|
+
component_label: 'Feedback Button',
|
|
483
|
+
page_url: 'https://example.com/app',
|
|
484
|
+
surface_ref: 'feedback-fab',
|
|
485
|
+
metadata: {
|
|
486
|
+
screenshots: [
|
|
487
|
+
{
|
|
488
|
+
url: 'https://example.com/screenshots/fixture-1.png',
|
|
489
|
+
timestamp: '2026-05-05T09:00:00Z',
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
note: 'Fixture issue report with screenshots for local issue-reporting flows.',
|
|
495
|
+
reporter_role_hint: 'member',
|
|
496
|
+
},
|
|
497
|
+
reply_request: {
|
|
498
|
+
note: 'Fixture follow-up after a support response.',
|
|
499
|
+
reporter_role_hint: 'member',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
seed: [
|
|
504
|
+
{
|
|
505
|
+
as: 'persona',
|
|
506
|
+
method: 'POST',
|
|
507
|
+
path: '/api/v1/issue-reports',
|
|
508
|
+
expected_statuses: [201],
|
|
509
|
+
body: {
|
|
510
|
+
target: {
|
|
511
|
+
component_key: 'feedback-button',
|
|
512
|
+
component_label: 'Feedback Button',
|
|
513
|
+
page_url: 'https://example.com/app',
|
|
514
|
+
surface_ref: 'feedback-fab',
|
|
515
|
+
metadata: {
|
|
516
|
+
screenshots: [
|
|
517
|
+
{
|
|
518
|
+
url: 'https://example.com/screenshots/fixture-1.png',
|
|
519
|
+
timestamp: '2026-05-05T09:00:00Z',
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
note: 'Fixture issue report with screenshots for local issue-reporting flows.',
|
|
525
|
+
reporter_role_hint: 'member',
|
|
526
|
+
},
|
|
527
|
+
capture: {
|
|
528
|
+
existing_issue_report_id: 'id',
|
|
529
|
+
existing_issue_case_id: 'linked_case.case_id',
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
}),
|
|
534
|
+
makeDerivedPersona('issue-reporter-blocked', 'Issue Reporter Blocked', 'user', {
|
|
535
|
+
permissions: [],
|
|
536
|
+
selector: {
|
|
537
|
+
query_param: { _user: 'user' },
|
|
538
|
+
headers: {
|
|
539
|
+
'X-Test-User': 'user',
|
|
540
|
+
'X-API-Key': '{{seed.blocked_app_api_key}}',
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
application: {
|
|
544
|
+
application_id: '{{seed.blocked_app_id}}',
|
|
545
|
+
application_slug: '{{seed.blocked_app_slug}}',
|
|
546
|
+
api_key: '{{seed.blocked_app_api_key}}',
|
|
547
|
+
},
|
|
548
|
+
scenario: {
|
|
549
|
+
issue_reporting: {
|
|
550
|
+
expected_status: 403,
|
|
551
|
+
required_capability: 'issue_reporting',
|
|
552
|
+
create_request: {
|
|
553
|
+
target: {
|
|
554
|
+
component_key: 'feedback-button',
|
|
555
|
+
component_label: 'Feedback Button',
|
|
556
|
+
page_url: 'https://example.com/app',
|
|
557
|
+
surface_ref: 'feedback-fab',
|
|
558
|
+
metadata: {},
|
|
559
|
+
},
|
|
560
|
+
note: 'Blocked issue reporter fixture should fail until capability is granted.',
|
|
561
|
+
reporter_role_hint: 'member',
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
seed: [
|
|
566
|
+
{
|
|
567
|
+
as: 'admin',
|
|
568
|
+
method: 'POST',
|
|
569
|
+
path: '/api/admin/create-app',
|
|
570
|
+
expected_statuses: [201],
|
|
571
|
+
body: {
|
|
572
|
+
name: 'Fixture Issue Reporting Blocked {{global.run_id}}',
|
|
573
|
+
slug: 'fixture-ir-blocked-{{global.run_id}}',
|
|
574
|
+
settings: {
|
|
575
|
+
issue_reporting_required_capability: 'issue_reporting',
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
capture: {
|
|
579
|
+
blocked_app_api_key: 'api_key',
|
|
580
|
+
blocked_app_id: 'application.id',
|
|
581
|
+
blocked_app_slug: 'application.slug',
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
}),
|
|
586
|
+
];
|
|
587
|
+
|
|
98
588
|
const DEFAULT_ROLE_GRANTS = {
|
|
99
589
|
user: ['user'],
|
|
100
590
|
admin: ['admin', 'super_admin'],
|
|
101
591
|
premium: ['user'],
|
|
592
|
+
'dayrate-guest': ['user'],
|
|
593
|
+
'dayrate-entitled': ['user'],
|
|
594
|
+
'dayrate-overage': ['user'],
|
|
595
|
+
'email-local-safe': ['user'],
|
|
596
|
+
'webhook-signature-invalid': ['admin', 'super_admin'],
|
|
597
|
+
'webhook-replay': ['admin', 'super_admin'],
|
|
598
|
+
'policy-admin-allow': ['admin', 'super_admin'],
|
|
599
|
+
'policy-denied-no-wallet': ['user'],
|
|
600
|
+
'issue-reporter': ['user'],
|
|
601
|
+
'issue-reporter-blocked': ['user'],
|
|
102
602
|
};
|
|
103
603
|
|
|
104
604
|
const DEFAULT_ENTITLEMENT_GRANTS = {
|
|
105
605
|
user: [],
|
|
106
606
|
admin: ['paid_access', 'admin_console'],
|
|
107
607
|
premium: ['paid_access'],
|
|
608
|
+
'dayrate-guest': [],
|
|
609
|
+
'dayrate-entitled': ['fixture.dayrate.free.{{global.run_id}}'],
|
|
610
|
+
'dayrate-overage': ['fixture.dayrate.overage.{{global.run_id}}'],
|
|
611
|
+
'email-local-safe': [],
|
|
612
|
+
'webhook-signature-invalid': [],
|
|
613
|
+
'webhook-replay': [],
|
|
614
|
+
'policy-admin-allow': [],
|
|
615
|
+
'policy-denied-no-wallet': [],
|
|
616
|
+
'issue-reporter': [],
|
|
617
|
+
'issue-reporter-blocked': [],
|
|
108
618
|
};
|
|
109
619
|
|
|
110
620
|
function createCliError(code, message) {
|
|
@@ -256,7 +766,7 @@ Repo-local SPAPS auth fixtures live here.
|
|
|
256
766
|
What to edit:
|
|
257
767
|
|
|
258
768
|
- \`app.json\`: local SPAPS server and browser target settings
|
|
259
|
-
- \`users.json\`: personas, profile data, selector hints, and custom browser storage
|
|
769
|
+
- \`users.json\`: personas, profile data, scenario metadata, optional seed steps, selector hints, and custom browser storage
|
|
260
770
|
- \`roles.json\`: persona-to-role grants
|
|
261
771
|
- \`entitlements.json\`: persona-to-entitlement grants
|
|
262
772
|
|
|
@@ -272,8 +782,14 @@ Common workflow:
|
|
|
272
782
|
1. Run \`npx spaps fixtures init\`
|
|
273
783
|
2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
|
|
274
784
|
3. Run \`npx spaps fixtures apply\`
|
|
275
|
-
4.
|
|
276
|
-
5.
|
|
785
|
+
4. Run \`npx spaps fixtures apply --seed --persona <code>\` when a domain persona needs real local DB state
|
|
786
|
+
5. Point Playwright or local scripts at the generated browser artifacts
|
|
787
|
+
6. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
|
|
788
|
+
|
|
789
|
+
Notes:
|
|
790
|
+
|
|
791
|
+
- \`--seed\` only runs against a reachable SPAPS server with local mode active
|
|
792
|
+
- Seed steps use the persona metadata already in \`.spaps/users.json\`; no extra backend-only fixture routes are required
|
|
277
793
|
`;
|
|
278
794
|
}
|
|
279
795
|
|
|
@@ -405,6 +921,322 @@ function ensurePersona(usersConfig, code) {
|
|
|
405
921
|
return persona;
|
|
406
922
|
}
|
|
407
923
|
|
|
924
|
+
function buildFixtureRunState(appConfig, runtime) {
|
|
925
|
+
const now = new Date();
|
|
926
|
+
const generatedAt = now.toISOString();
|
|
927
|
+
return {
|
|
928
|
+
global: {
|
|
929
|
+
generated_at: generatedAt,
|
|
930
|
+
epoch: Math.floor(now.getTime() / 1000),
|
|
931
|
+
run_id: generatedAt
|
|
932
|
+
.replace(/[-:]/g, '')
|
|
933
|
+
.replace(/\.\d+Z$/, 'z')
|
|
934
|
+
.replace('T', 't'),
|
|
935
|
+
},
|
|
936
|
+
runtime: {
|
|
937
|
+
api_url: appConfig.server.api_url,
|
|
938
|
+
application_id: appConfig.server.application_id,
|
|
939
|
+
application_slug: appConfig.server.application_slug,
|
|
940
|
+
local_mode_active: appConfig.server.local_mode_active,
|
|
941
|
+
running: appConfig.server.running,
|
|
942
|
+
environment: runtime?.local_mode?.environment || appConfig.server.environment || null,
|
|
943
|
+
},
|
|
944
|
+
seed: {},
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function getNestedValue(source, dottedPath) {
|
|
949
|
+
if (!source || typeof source !== 'object' || !dottedPath) {
|
|
950
|
+
return undefined;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return String(dottedPath)
|
|
954
|
+
.split('.')
|
|
955
|
+
.filter(Boolean)
|
|
956
|
+
.reduce((current, segment) => {
|
|
957
|
+
if (current === undefined || current === null) {
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (Array.isArray(current)) {
|
|
962
|
+
const index = Number(segment);
|
|
963
|
+
return Number.isInteger(index) ? current[index] : undefined;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return current[segment];
|
|
967
|
+
}, source);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function resolveTemplateLookup(context, expression) {
|
|
971
|
+
const lookup = String(expression || '').trim();
|
|
972
|
+
if (!lookup) {
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (!lookup.includes('.') && Object.prototype.hasOwnProperty.call(context, lookup)) {
|
|
977
|
+
return context[lookup];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return getNestedValue(context, lookup);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function renderTemplateValue(value, context) {
|
|
984
|
+
if (typeof value === 'string') {
|
|
985
|
+
const matches = [...value.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)];
|
|
986
|
+
if (matches.length === 0) {
|
|
987
|
+
return value;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (matches.length === 1 && matches[0][0] === value) {
|
|
991
|
+
const resolved = resolveTemplateLookup(context, matches[0][1]);
|
|
992
|
+
return resolved === undefined ? value : cloneValue(resolved);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return value.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, expression) => {
|
|
996
|
+
const resolved = resolveTemplateLookup(context, expression);
|
|
997
|
+
return resolved === undefined ? match : String(resolved);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (Array.isArray(value)) {
|
|
1002
|
+
return value.map((entry) => renderTemplateValue(entry, context));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (value && typeof value === 'object') {
|
|
1006
|
+
return Object.fromEntries(
|
|
1007
|
+
Object.entries(value).map(([key, entry]) => [key, renderTemplateValue(entry, context)])
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return value;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function unwrapEnvelope(payload) {
|
|
1015
|
+
if (
|
|
1016
|
+
payload &&
|
|
1017
|
+
typeof payload === 'object' &&
|
|
1018
|
+
payload.success === true &&
|
|
1019
|
+
Object.prototype.hasOwnProperty.call(payload, 'data')
|
|
1020
|
+
) {
|
|
1021
|
+
return payload.data;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return payload;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function describeStatuses(statuses = []) {
|
|
1028
|
+
if (!Array.isArray(statuses) || statuses.length === 0) {
|
|
1029
|
+
return '2xx';
|
|
1030
|
+
}
|
|
1031
|
+
return statuses.join(', ');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function buildApplicationSummary(appConfig, persona = {}) {
|
|
1035
|
+
const override = persona.application || {};
|
|
1036
|
+
return {
|
|
1037
|
+
api_url: override.api_url || appConfig.server.api_url,
|
|
1038
|
+
application_id: override.application_id || override.id || appConfig.server.application_id || null,
|
|
1039
|
+
application_slug: override.application_slug || override.slug || appConfig.server.application_slug || null,
|
|
1040
|
+
api_key: override.api_key || null,
|
|
1041
|
+
local_mode_active: appConfig.server.local_mode_active,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function buildPersonaTemplateContext(appConfig, persona, fixtureRunState) {
|
|
1046
|
+
return {
|
|
1047
|
+
global: fixtureRunState.global,
|
|
1048
|
+
runtime: fixtureRunState.runtime,
|
|
1049
|
+
seed: fixtureRunState.seed,
|
|
1050
|
+
persona,
|
|
1051
|
+
application: buildApplicationSummary(appConfig, persona),
|
|
1052
|
+
scenario: persona.scenario || {},
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function resolvePersonaDefinition(appConfig, persona, fixtureRunState) {
|
|
1057
|
+
return renderTemplateValue(cloneValue(persona), buildPersonaTemplateContext(appConfig, persona, fixtureRunState));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function resolveConfigTemplates(config, fixtureRunState) {
|
|
1061
|
+
return renderTemplateValue(cloneValue(config), fixtureRunState);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function resolveSeedActor({ appConfig, usersConfig, persona, actor = 'persona', fixtureRunState }) {
|
|
1065
|
+
const actorCode = actor === 'persona' ? persona.code : actor;
|
|
1066
|
+
const actorPersona = ensurePersona(usersConfig, actorCode);
|
|
1067
|
+
return resolvePersonaDefinition(appConfig, actorPersona, fixtureRunState);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function buildSeedUrl(apiUrl, requestPath, queryParams = {}) {
|
|
1071
|
+
const url = new URL(requestPath, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
|
1072
|
+
for (const [key, value] of Object.entries(queryParams || {})) {
|
|
1073
|
+
if (value === undefined || value === null || value === '') {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
url.searchParams.set(key, String(value));
|
|
1077
|
+
}
|
|
1078
|
+
return url.toString();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function assertSeedingRuntime(runtime, port) {
|
|
1082
|
+
if (!runtime?.running) {
|
|
1083
|
+
throw createCliError(
|
|
1084
|
+
'ESEED',
|
|
1085
|
+
`Fixture seeding requires a running SPAPS server on port ${port}. Start it with "npx spaps local --port ${port}" or omit --seed.`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (runtime.local_mode?.active !== true) {
|
|
1090
|
+
throw createCliError(
|
|
1091
|
+
'ESEED',
|
|
1092
|
+
'Fixture seeding requires SPAPS local mode so persona routing can use X-Test-User headers. Enable local mode or omit --seed.'
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function runSeedSequence({
|
|
1098
|
+
appConfig,
|
|
1099
|
+
usersConfig,
|
|
1100
|
+
persona,
|
|
1101
|
+
fixtureRunState,
|
|
1102
|
+
}) {
|
|
1103
|
+
const steps = Array.isArray(persona.seed) ? persona.seed : [];
|
|
1104
|
+
if (steps.length === 0) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const requests = [];
|
|
1109
|
+
const captures = {};
|
|
1110
|
+
|
|
1111
|
+
for (const step of steps) {
|
|
1112
|
+
const resolvedPersona = resolvePersonaDefinition(appConfig, persona, fixtureRunState);
|
|
1113
|
+
const actor = resolveSeedActor({
|
|
1114
|
+
appConfig,
|
|
1115
|
+
usersConfig,
|
|
1116
|
+
persona,
|
|
1117
|
+
actor: step.as || 'persona',
|
|
1118
|
+
fixtureRunState,
|
|
1119
|
+
});
|
|
1120
|
+
const templateContext = buildPersonaTemplateContext(appConfig, resolvedPersona, fixtureRunState);
|
|
1121
|
+
const resolvedStep = renderTemplateValue(cloneValue(step), templateContext);
|
|
1122
|
+
const application = buildApplicationSummary(appConfig, actor);
|
|
1123
|
+
const headers = {
|
|
1124
|
+
Accept: 'application/json',
|
|
1125
|
+
...(resolvedStep.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1126
|
+
...(actor.selector?.headers || {}),
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
if (application.api_key && !headers['X-API-Key']) {
|
|
1130
|
+
headers['X-API-Key'] = application.api_key;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const url = buildSeedUrl(
|
|
1134
|
+
appConfig.server.api_url,
|
|
1135
|
+
resolvedStep.path,
|
|
1136
|
+
actor.selector?.query_param || {}
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
let response;
|
|
1140
|
+
try {
|
|
1141
|
+
response = await axios({
|
|
1142
|
+
method: resolvedStep.method || 'GET',
|
|
1143
|
+
url,
|
|
1144
|
+
data: resolvedStep.body || null,
|
|
1145
|
+
headers,
|
|
1146
|
+
timeout: 5000,
|
|
1147
|
+
validateStatus: () => true,
|
|
1148
|
+
});
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
throw createCliError(
|
|
1151
|
+
'ESEED',
|
|
1152
|
+
`Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: ${error.message}`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const expectedStatuses =
|
|
1157
|
+
Array.isArray(resolvedStep.expected_statuses) && resolvedStep.expected_statuses.length > 0
|
|
1158
|
+
? resolvedStep.expected_statuses
|
|
1159
|
+
: null;
|
|
1160
|
+
const ok = expectedStatuses
|
|
1161
|
+
? expectedStatuses.includes(response.status)
|
|
1162
|
+
: response.status >= 200 && response.status < 300;
|
|
1163
|
+
|
|
1164
|
+
if (!ok) {
|
|
1165
|
+
throw createCliError(
|
|
1166
|
+
'ESEED',
|
|
1167
|
+
`Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: expected ${describeStatuses(expectedStatuses)}, received ${response.status}.`
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const payload = unwrapEnvelope(response.data);
|
|
1172
|
+
const captured = {};
|
|
1173
|
+
for (const [key, lookup] of Object.entries(resolvedStep.capture || {})) {
|
|
1174
|
+
const capturedValue = getNestedValue(payload, lookup);
|
|
1175
|
+
if (capturedValue === undefined) {
|
|
1176
|
+
throw createCliError(
|
|
1177
|
+
'ESEED',
|
|
1178
|
+
`Fixture seed request for persona "${persona.code}" could not capture "${key}" from "${lookup}" at ${resolvedStep.method || 'GET'} ${resolvedStep.path}.`
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
fixtureRunState.seed[key] = capturedValue;
|
|
1183
|
+
captures[key] = capturedValue;
|
|
1184
|
+
captured[key] = capturedValue;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
requests.push({
|
|
1188
|
+
as: actor.code,
|
|
1189
|
+
method: resolvedStep.method || 'GET',
|
|
1190
|
+
path: resolvedStep.path,
|
|
1191
|
+
status: response.status,
|
|
1192
|
+
expected_statuses: expectedStatuses,
|
|
1193
|
+
captured,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return {
|
|
1198
|
+
persona: persona.code,
|
|
1199
|
+
request_count: requests.length,
|
|
1200
|
+
captures,
|
|
1201
|
+
requests,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function runFixtureSeeding({
|
|
1206
|
+
appConfig,
|
|
1207
|
+
runtime,
|
|
1208
|
+
usersConfig,
|
|
1209
|
+
personaCode = null,
|
|
1210
|
+
fixtureRunState,
|
|
1211
|
+
port,
|
|
1212
|
+
}) {
|
|
1213
|
+
assertSeedingRuntime(runtime, port);
|
|
1214
|
+
|
|
1215
|
+
const selectedPersonas = personaCode
|
|
1216
|
+
? [ensurePersona(usersConfig, personaCode)]
|
|
1217
|
+
: usersConfig.personas;
|
|
1218
|
+
const seeded = [];
|
|
1219
|
+
|
|
1220
|
+
for (const persona of selectedPersonas) {
|
|
1221
|
+
const result = await runSeedSequence({
|
|
1222
|
+
appConfig,
|
|
1223
|
+
usersConfig,
|
|
1224
|
+
persona,
|
|
1225
|
+
fixtureRunState,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
if (result) {
|
|
1229
|
+
seeded.push(result);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
return {
|
|
1234
|
+
enabled: true,
|
|
1235
|
+
personas: seeded,
|
|
1236
|
+
captures: { ...fixtureRunState.seed },
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
408
1240
|
function stringifyStorageValue(value) {
|
|
409
1241
|
if (typeof value === 'string') {
|
|
410
1242
|
return value;
|
|
@@ -440,6 +1272,7 @@ function base64UrlEncodeJson(value) {
|
|
|
440
1272
|
function buildFixtureTokens(appConfig, persona, roles) {
|
|
441
1273
|
const now = Math.floor(Date.now() / 1000);
|
|
442
1274
|
const primaryRole = roles[0] || 'user';
|
|
1275
|
+
const application = buildApplicationSummary(appConfig, persona);
|
|
443
1276
|
const header = base64UrlEncodeJson({ alg: 'none', typ: 'JWT' });
|
|
444
1277
|
const payload = base64UrlEncodeJson({
|
|
445
1278
|
sub: persona.profile?.user_id,
|
|
@@ -448,8 +1281,8 @@ function buildFixtureTokens(appConfig, persona, roles) {
|
|
|
448
1281
|
role: primaryRole,
|
|
449
1282
|
roles,
|
|
450
1283
|
tier: persona.profile?.tier || 'free',
|
|
451
|
-
app_id:
|
|
452
|
-
aud:
|
|
1284
|
+
app_id: application.application_id,
|
|
1285
|
+
aud: application.application_slug || 'spaps-fixture',
|
|
453
1286
|
iss: 'spaps-fixture',
|
|
454
1287
|
iat: now,
|
|
455
1288
|
exp: now + (60 * 60 * 24 * 30),
|
|
@@ -465,10 +1298,11 @@ function buildFixtureTokens(appConfig, persona, roles) {
|
|
|
465
1298
|
|
|
466
1299
|
function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
|
|
467
1300
|
const entitlementKeys = entitlementsConfig.grants?.[persona.code] || [];
|
|
1301
|
+
const application = buildApplicationSummary(appConfig, persona);
|
|
468
1302
|
|
|
469
1303
|
return entitlementKeys.map((entitlementKey, index) => ({
|
|
470
1304
|
id: `fixture-${persona.code}-${index + 1}`,
|
|
471
|
-
application_id:
|
|
1305
|
+
application_id: application.application_id,
|
|
472
1306
|
beneficiary_user_id: persona.profile?.user_id || null,
|
|
473
1307
|
beneficiary_email: persona.profile?.email || null,
|
|
474
1308
|
entitlement_key: entitlementKey,
|
|
@@ -490,6 +1324,7 @@ function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
|
|
|
490
1324
|
const entitlements = entitlementsConfig.grants?.[persona.code] || [];
|
|
491
1325
|
const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
|
|
492
1326
|
const primaryRole = roles[0] || 'user';
|
|
1327
|
+
const application = buildApplicationSummary(appConfig, persona);
|
|
493
1328
|
|
|
494
1329
|
return {
|
|
495
1330
|
id: persona.profile?.user_id || null,
|
|
@@ -503,7 +1338,7 @@ function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
|
|
|
503
1338
|
is_super_admin: roles.includes('super_admin'),
|
|
504
1339
|
entitlements,
|
|
505
1340
|
active_entitlements: entitlements.map((entitlementKey) => ({ key: entitlementKey })),
|
|
506
|
-
application_id:
|
|
1341
|
+
application_id: application.application_id || null,
|
|
507
1342
|
fixture_persona: persona.code,
|
|
508
1343
|
};
|
|
509
1344
|
}
|
|
@@ -544,6 +1379,7 @@ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
|
|
|
544
1379
|
display_name: persona.display_name,
|
|
545
1380
|
selector: persona.selector || {},
|
|
546
1381
|
route_hint: buildRouteHint(appConfig, persona),
|
|
1382
|
+
application: buildApplicationSummary(appConfig, persona),
|
|
547
1383
|
user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
|
|
548
1384
|
tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
|
|
549
1385
|
entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
|
|
@@ -605,7 +1441,7 @@ function buildDevAuthBridgeScript(config) {
|
|
|
605
1441
|
localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
|
|
606
1442
|
localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
|
|
607
1443
|
localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
|
|
608
|
-
localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(CONFIG.application));
|
|
1444
|
+
localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(persona.application || CONFIG.application));
|
|
609
1445
|
localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
|
|
610
1446
|
auth_mode: CONFIG.auth_mode,
|
|
611
1447
|
local_mode_active: CONFIG.local_mode_active
|
|
@@ -841,6 +1677,7 @@ function buildDevAuthBridgeScript(config) {
|
|
|
841
1677
|
function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlementsConfig) {
|
|
842
1678
|
const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
|
|
843
1679
|
const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
|
|
1680
|
+
const application = buildApplicationSummary(appConfig, persona);
|
|
844
1681
|
const localStorage = [
|
|
845
1682
|
{
|
|
846
1683
|
name: FIXTURE_KEYS.active_persona,
|
|
@@ -864,11 +1701,7 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
|
|
|
864
1701
|
},
|
|
865
1702
|
{
|
|
866
1703
|
name: FIXTURE_KEYS.application,
|
|
867
|
-
value: stringifyStorageValue(
|
|
868
|
-
api_url: appConfig.server.api_url,
|
|
869
|
-
application_id: appConfig.server.application_id,
|
|
870
|
-
application_slug: appConfig.server.application_slug,
|
|
871
|
-
}),
|
|
1704
|
+
value: stringifyStorageValue(application),
|
|
872
1705
|
},
|
|
873
1706
|
{
|
|
874
1707
|
name: FIXTURE_KEYS.runtime,
|
|
@@ -921,6 +1754,7 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
|
|
|
921
1754
|
function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig, paths) {
|
|
922
1755
|
const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
|
|
923
1756
|
const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
|
|
1757
|
+
const application = buildApplicationSummary(appConfig, persona);
|
|
924
1758
|
return {
|
|
925
1759
|
persona: persona.code,
|
|
926
1760
|
display_name: persona.display_name,
|
|
@@ -928,16 +1762,12 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
|
|
|
928
1762
|
route_hint: buildRouteHint(appConfig, persona),
|
|
929
1763
|
selector: persona.selector || {},
|
|
930
1764
|
profile: persona.profile || {},
|
|
1765
|
+
scenario: persona.scenario || {},
|
|
931
1766
|
roles: rolesConfig.grants?.[persona.code] || [],
|
|
932
1767
|
entitlements: entitlementsConfig.grants?.[persona.code] || [],
|
|
933
1768
|
user,
|
|
934
1769
|
tokens,
|
|
935
|
-
application
|
|
936
|
-
api_url: appConfig.server.api_url,
|
|
937
|
-
application_id: appConfig.server.application_id,
|
|
938
|
-
application_slug: appConfig.server.application_slug,
|
|
939
|
-
local_mode_active: appConfig.server.local_mode_active,
|
|
940
|
-
},
|
|
1770
|
+
application,
|
|
941
1771
|
artifacts: {
|
|
942
1772
|
storage_state_path: path.join(paths.browserDir, `${persona.code}.storage-state.json`),
|
|
943
1773
|
headers_path: path.join(paths.browserDir, `${persona.code}.headers.json`),
|
|
@@ -946,7 +1776,16 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
|
|
|
946
1776
|
};
|
|
947
1777
|
}
|
|
948
1778
|
|
|
949
|
-
function writePersonaArtifacts({
|
|
1779
|
+
function writePersonaArtifacts({
|
|
1780
|
+
rootDir,
|
|
1781
|
+
paths,
|
|
1782
|
+
appConfig,
|
|
1783
|
+
users,
|
|
1784
|
+
roles,
|
|
1785
|
+
entitlements,
|
|
1786
|
+
personaCode = null,
|
|
1787
|
+
seedResultsByPersona = {},
|
|
1788
|
+
}) {
|
|
950
1789
|
const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
|
|
951
1790
|
const generated = [];
|
|
952
1791
|
|
|
@@ -955,6 +1794,7 @@ function writePersonaArtifacts({ rootDir, paths, appConfig, users, roles, entitl
|
|
|
955
1794
|
const storageState = buildStorageStateArtifact(appConfig, persona, roles, entitlements);
|
|
956
1795
|
const headers = buildHeaderArtifact(appConfig, persona);
|
|
957
1796
|
const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
|
|
1797
|
+
context.seed = seedResultsByPersona[code] || null;
|
|
958
1798
|
|
|
959
1799
|
writeJson(context.artifacts.storage_state_path, storageState);
|
|
960
1800
|
writeJson(context.artifacts.headers_path, headers);
|
|
@@ -1000,6 +1840,8 @@ async function applyFixtures({
|
|
|
1000
1840
|
baseUrl = null,
|
|
1001
1841
|
version = '0.0.0',
|
|
1002
1842
|
persona = null,
|
|
1843
|
+
seed = false,
|
|
1844
|
+
subcommand = 'apply',
|
|
1003
1845
|
} = {}) {
|
|
1004
1846
|
const rootDir = resolveRepoRoot(dir);
|
|
1005
1847
|
const paths = resolveFixturePaths(rootDir);
|
|
@@ -1029,32 +1871,55 @@ async function applyFixtures({
|
|
|
1029
1871
|
files_skipped: [],
|
|
1030
1872
|
});
|
|
1031
1873
|
|
|
1874
|
+
const fixtureRunState = buildFixtureRunState(appConfig, runtime);
|
|
1875
|
+
const seeding = seed
|
|
1876
|
+
? await runFixtureSeeding({
|
|
1877
|
+
appConfig,
|
|
1878
|
+
runtime,
|
|
1879
|
+
usersConfig: kernel.users,
|
|
1880
|
+
personaCode: persona,
|
|
1881
|
+
fixtureRunState,
|
|
1882
|
+
port,
|
|
1883
|
+
})
|
|
1884
|
+
: null;
|
|
1885
|
+
const resolvedUsers = {
|
|
1886
|
+
...kernel.users,
|
|
1887
|
+
personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
|
|
1888
|
+
};
|
|
1889
|
+
const resolvedRoles = resolveConfigTemplates(kernel.roles, fixtureRunState);
|
|
1890
|
+
const resolvedEntitlements = resolveConfigTemplates(kernel.entitlements, fixtureRunState);
|
|
1891
|
+
const seedResultsByPersona = Object.fromEntries(
|
|
1892
|
+
(seeding?.personas || []).map((entry) => [entry.persona, entry])
|
|
1893
|
+
);
|
|
1894
|
+
|
|
1032
1895
|
const generated = writePersonaArtifacts({
|
|
1033
1896
|
rootDir,
|
|
1034
1897
|
paths,
|
|
1035
1898
|
appConfig,
|
|
1036
|
-
users:
|
|
1037
|
-
roles:
|
|
1038
|
-
entitlements:
|
|
1899
|
+
users: resolvedUsers,
|
|
1900
|
+
roles: resolvedRoles,
|
|
1901
|
+
entitlements: resolvedEntitlements,
|
|
1039
1902
|
personaCode: persona,
|
|
1903
|
+
seedResultsByPersona,
|
|
1040
1904
|
});
|
|
1041
1905
|
const bridge = writeDevAuthBridge({
|
|
1042
1906
|
rootDir,
|
|
1043
1907
|
paths,
|
|
1044
1908
|
appConfig,
|
|
1045
|
-
users:
|
|
1046
|
-
roles:
|
|
1047
|
-
entitlements:
|
|
1909
|
+
users: resolvedUsers,
|
|
1910
|
+
roles: resolvedRoles,
|
|
1911
|
+
entitlements: resolvedEntitlements,
|
|
1048
1912
|
});
|
|
1049
1913
|
|
|
1050
1914
|
return {
|
|
1051
1915
|
success: true,
|
|
1052
1916
|
command: 'fixtures',
|
|
1053
|
-
subcommand
|
|
1917
|
+
subcommand,
|
|
1054
1918
|
root_dir: rootDir,
|
|
1055
1919
|
fixture_dir: paths.fixtureDir,
|
|
1056
1920
|
bootstrapped,
|
|
1057
1921
|
runtime,
|
|
1922
|
+
seeding,
|
|
1058
1923
|
generated: {
|
|
1059
1924
|
personas: generated,
|
|
1060
1925
|
bridge,
|
|
@@ -1074,7 +1939,7 @@ async function applyFixtures({
|
|
|
1074
1939
|
}
|
|
1075
1940
|
|
|
1076
1941
|
async function exportStorageState(options = {}) {
|
|
1077
|
-
const result = await applyFixtures(options);
|
|
1942
|
+
const result = await applyFixtures({ ...options, subcommand: 'storage-state' });
|
|
1078
1943
|
const personaArtifact = result.generated.personas[0];
|
|
1079
1944
|
return {
|
|
1080
1945
|
success: true,
|
|
@@ -1089,6 +1954,7 @@ async function exportStorageState(options = {}) {
|
|
|
1089
1954
|
route_hint: personaArtifact.route_hint,
|
|
1090
1955
|
bridge: result.generated.bridge,
|
|
1091
1956
|
runtime: result.runtime,
|
|
1957
|
+
seeding: result.seeding,
|
|
1092
1958
|
next_steps: result.next_steps,
|
|
1093
1959
|
};
|
|
1094
1960
|
}
|
|
@@ -1098,6 +1964,7 @@ async function resetFixtures({
|
|
|
1098
1964
|
port = DEFAULT_PORT,
|
|
1099
1965
|
baseUrl = null,
|
|
1100
1966
|
version = '0.0.0',
|
|
1967
|
+
seed = false,
|
|
1101
1968
|
} = {}) {
|
|
1102
1969
|
const rootDir = resolveRepoRoot(dir);
|
|
1103
1970
|
const paths = resolveFixturePaths(rootDir);
|
|
@@ -1114,6 +1981,7 @@ async function resetFixtures({
|
|
|
1114
1981
|
port,
|
|
1115
1982
|
baseUrl,
|
|
1116
1983
|
version,
|
|
1984
|
+
seed,
|
|
1117
1985
|
});
|
|
1118
1986
|
|
|
1119
1987
|
return {
|
|
@@ -1127,6 +1995,7 @@ async function resetFixtures({
|
|
|
1127
1995
|
files_overwritten: initResult.files_overwritten,
|
|
1128
1996
|
generated: applyResult.generated,
|
|
1129
1997
|
runtime: applyResult.runtime,
|
|
1998
|
+
seeding: applyResult.seeding,
|
|
1130
1999
|
next_steps: applyResult.next_steps,
|
|
1131
2000
|
};
|
|
1132
2001
|
}
|