@ossy/platform 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +6 -7
- package/package.json +8 -7
- package/src/index.js +1 -0
- package/src/proxy-internal.js +29 -5
- package/src/proxy-internal.spec.js +62 -0
- package/src/runtime.js +13 -2
- package/src/server.js +18 -0
- package/src/site-loader.js +1 -1
- package/src/tasks/task-service.js +15 -12
- package/src/test/flow-runner.js +211 -70
- package/src/test/flow-shard.js +66 -0
- package/src/test/flow-shard.spec.js +73 -0
- package/src/test/test.util.js +37 -0
- package/src/verify-sign-in-session.js +24 -0
package/Dockerfile
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
# Build context: ossy/packages/platform directory.
|
|
2
|
-
# From repo root: docker build -f
|
|
3
|
-
#
|
|
2
|
+
# From repo root: docker build -f packages/platform/Dockerfile packages/platform
|
|
3
|
+
# Pushed by the manual "Deploy platform runtime" workflow (not npm Publish).
|
|
4
4
|
FROM node:24-bookworm-slim
|
|
5
5
|
|
|
6
6
|
WORKDIR /app
|
|
7
7
|
|
|
8
8
|
COPY package.json ./
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error
|
|
10
|
+
# Production deps from npm. --legacy-peer-deps: transitive peers on an older
|
|
11
|
+
# lockstep patch must not fail the image when ranges are otherwise valid.
|
|
12
|
+
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error --legacy-peer-deps
|
|
14
13
|
|
|
15
14
|
COPY src ./src
|
|
16
15
|
COPY docker-healthcheck.js ./docker-healthcheck.js
|
|
@@ -29,7 +28,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|
|
29
28
|
|
|
30
29
|
# Required env vars at runtime:
|
|
31
30
|
# OSSY_API_KEY — Ossy API JWT for CMS reads
|
|
32
|
-
# OSSY_API_URL — (optional) override API base, default https://
|
|
31
|
+
# OSSY_API_URL — (optional) override API base, default https://ossy.se/api/v0
|
|
33
32
|
# PORT — (optional) override listen port, default 3000
|
|
34
33
|
# OSSY_SERVICE_NAME — (optional) `/health` service label, default runtime
|
|
35
34
|
CMD ["node", "src/runtime.js"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./test/flow-runner.js": "./src/test/flow-runner.js",
|
|
28
28
|
"./test/playwright.config.js": "./src/test/playwright.config.js",
|
|
29
29
|
"./locale": "./src/locale.js",
|
|
30
|
+
"./verify-sign-in-session": "./src/verify-sign-in-session.js",
|
|
30
31
|
"./storage-keys": "./src/storage/storage-keys.js",
|
|
31
32
|
"./mcp": "./src/mcp/mount-platform-mcp.js"
|
|
32
33
|
},
|
|
@@ -45,16 +46,16 @@
|
|
|
45
46
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
46
47
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
48
|
"@ossy/config": "^3.0.9",
|
|
48
|
-
"@ossy/event-store": "^3.
|
|
49
|
+
"@ossy/event-store": "^3.12.0",
|
|
49
50
|
"@ossy/locale": "^3.4.0",
|
|
50
51
|
"@ossy/manifest": "^3.9.0",
|
|
51
52
|
"@ossy/observability": "^3.0.9",
|
|
52
53
|
"@ossy/policies": "^3.0.9",
|
|
53
54
|
"@ossy/schema": "^3.8.0",
|
|
54
|
-
"@ossy/sdk": "^3.
|
|
55
|
-
"@ossy/tokens": "^3.
|
|
56
|
-
"@ossy/users": "^3.
|
|
57
|
-
"@ossy/workspaces": "^3.
|
|
55
|
+
"@ossy/sdk": "^3.12.0",
|
|
56
|
+
"@ossy/tokens": "^3.11.1",
|
|
57
|
+
"@ossy/users": "^3.12.0",
|
|
58
|
+
"@ossy/workspaces": "^3.12.0",
|
|
58
59
|
"cookie-parser": "^1.4.7",
|
|
59
60
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
60
61
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -75,5 +76,5 @@
|
|
|
75
76
|
"Dockerfile",
|
|
76
77
|
"docker-healthcheck.js"
|
|
77
78
|
],
|
|
78
|
-
"gitHead": "
|
|
79
|
+
"gitHead": "c2650803219ad1831559b512848358d9550ffad2"
|
|
79
80
|
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, reso
|
|
|
8
8
|
export { IntegrationService } from './integration.service.js'
|
|
9
9
|
export { ConfigService } from './config.service.js'
|
|
10
10
|
export { ActionService } from './actions/action.service.js'
|
|
11
|
+
export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
11
12
|
export { TokenService } from './token.service.js'
|
|
12
13
|
export { UsersMiddleware } from './users.middleware.js'
|
|
13
14
|
export { WorkspacesMiddleware } from './workspaces.middleware.js'
|
package/src/proxy-internal.js
CHANGED
|
@@ -14,6 +14,25 @@ function normalizeWorkspaceIdHeader (value) {
|
|
|
14
14
|
return first || undefined
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Map `/@ossy…` to the same-process path (`/actions` or `/api/v0…`).
|
|
19
|
+
* @param {string} originalUrl
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function ossyProxyLocalPath (originalUrl) {
|
|
23
|
+
const pathAfterOssy = String(originalUrl || '').replace(/^\/@ossy/, '') || '/'
|
|
24
|
+
const pathOnly = pathAfterOssy.split('?')[0]
|
|
25
|
+
if (pathOnly === '/actions' || pathOnly === '/events') {
|
|
26
|
+
return pathAfterOssy.startsWith('/') ? pathAfterOssy : `/${pathAfterOssy}`
|
|
27
|
+
}
|
|
28
|
+
return `/api/v0${pathAfterOssy.startsWith('/') ? pathAfterOssy : `/${pathAfterOssy}`}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Absolute OSSY_API_URL → HTTP hop. Unset / relative → same Express app. */
|
|
32
|
+
export function isRemoteOssyApiUrl (envUrl = process.env.OSSY_API_URL) {
|
|
33
|
+
return /^https?:\/\//i.test(String(envUrl || '').trim())
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export function ProxyInternal () {
|
|
18
37
|
return (req, res, next) => {
|
|
19
38
|
if (!req.originalUrl.startsWith('/@ossy')) {
|
|
@@ -47,15 +66,20 @@ export function ProxyInternal () {
|
|
|
47
66
|
return
|
|
48
67
|
}
|
|
49
68
|
|
|
69
|
+
if (!isRemoteOssyApiUrl()) {
|
|
70
|
+
const dest = ossyProxyLocalPath(req.originalUrl)
|
|
71
|
+
log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl} → ${dest} (same origin)`)
|
|
72
|
+
req.url = dest
|
|
73
|
+
req.originalUrl = dest
|
|
74
|
+
return next()
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
|
|
51
78
|
|
|
52
|
-
const domain = (process.env.OSSY_API_URL
|
|
79
|
+
const domain = String(process.env.OSSY_API_URL).trim()
|
|
53
80
|
.replace(/\/api\/v0\/?$/, '')
|
|
54
81
|
.replace(/\/$/, '')
|
|
55
|
-
const
|
|
56
|
-
// POST /actions lives on the app server root — not under /api/v0.
|
|
57
|
-
const upstreamPath = pathAfterOssy === '/actions' ? '/actions' : `/api/v0${pathAfterOssy}`
|
|
58
|
-
const url = `${domain}${upstreamPath}`
|
|
82
|
+
const url = `${domain}${ossyProxyLocalPath(req.originalUrl)}`
|
|
59
83
|
const forwardedHeaders = JSON.parse(JSON.stringify(req.headers))
|
|
60
84
|
const workspaceId = normalizeWorkspaceIdHeader(req.get('workspaceId'))
|
|
61
85
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
isRemoteOssyApiUrl,
|
|
4
|
+
ossyProxyLocalPath,
|
|
5
|
+
ProxyInternal,
|
|
6
|
+
} from './proxy-internal.js'
|
|
7
|
+
|
|
8
|
+
describe('ossyProxyLocalPath', () => {
|
|
9
|
+
it('maps REST under /@ossy onto /api/v0', () => {
|
|
10
|
+
expect(ossyProxyLocalPath('/@ossy/users/me')).toBe('/api/v0/users/me')
|
|
11
|
+
expect(ossyProxyLocalPath('/@ossy/apps/ask?domain=ossy.se'))
|
|
12
|
+
.toBe('/api/v0/apps/ask?domain=ossy.se')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('keeps POST /actions and GET /events at the server root', () => {
|
|
16
|
+
expect(ossyProxyLocalPath('/@ossy/actions')).toBe('/actions')
|
|
17
|
+
expect(ossyProxyLocalPath('/@ossy/actions?x=1')).toBe('/actions?x=1')
|
|
18
|
+
expect(ossyProxyLocalPath('/@ossy/events')).toBe('/events')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('isRemoteOssyApiUrl', () => {
|
|
23
|
+
it('treats unset, empty, and relative values as same-origin', () => {
|
|
24
|
+
expect(isRemoteOssyApiUrl(undefined)).toBe(false)
|
|
25
|
+
expect(isRemoteOssyApiUrl('')).toBe(false)
|
|
26
|
+
expect(isRemoteOssyApiUrl('/api/v0')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('treats absolute http(s) as a remote hop', () => {
|
|
30
|
+
expect(isRemoteOssyApiUrl('https://ossy.se/api/v0')).toBe(true)
|
|
31
|
+
expect(isRemoteOssyApiUrl('http://localhost:3006')).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('ProxyInternal same-origin rewrite', () => {
|
|
36
|
+
const original = process.env.OSSY_API_URL
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (original === undefined) delete process.env.OSSY_API_URL
|
|
40
|
+
else process.env.OSSY_API_URL = original
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rewrites /@ossy onto this process when OSSY_API_URL is unset', () => {
|
|
44
|
+
delete process.env.OSSY_API_URL
|
|
45
|
+
const req = { originalUrl: '/@ossy/users/me', url: '/@ossy/users/me', method: 'GET' }
|
|
46
|
+
const next = jest.fn()
|
|
47
|
+
ProxyInternal()(req, {}, next)
|
|
48
|
+
expect(req.url).toBe('/api/v0/users/me')
|
|
49
|
+
expect(req.originalUrl).toBe('/api/v0/users/me')
|
|
50
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('rewrites /@ossy/actions onto POST /actions', () => {
|
|
54
|
+
delete process.env.OSSY_API_URL
|
|
55
|
+
const req = { originalUrl: '/@ossy/actions', url: '/@ossy/actions', method: 'POST' }
|
|
56
|
+
const next = jest.fn()
|
|
57
|
+
ProxyInternal()(req, {}, next)
|
|
58
|
+
expect(req.url).toBe('/actions')
|
|
59
|
+
expect(req.originalUrl).toBe('/actions')
|
|
60
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/runtime.js
CHANGED
|
@@ -71,13 +71,24 @@ async function getSiteContext (domain) {
|
|
|
71
71
|
*
|
|
72
72
|
* Environment variables:
|
|
73
73
|
* OSSY_API_KEY — API JWT for CMS reads (required)
|
|
74
|
-
* OSSY_API_URL — Override API base URL (default: https://
|
|
74
|
+
* OSSY_API_URL — Override API base URL (default: https://ossy.se/api/v0)
|
|
75
75
|
* PORT — Override listen port (default: 3000)
|
|
76
76
|
*/
|
|
77
77
|
export async function startRuntime ({ port } = {}) {
|
|
78
78
|
const resolvedPort = port ?? resolvePort()
|
|
79
79
|
|
|
80
80
|
const app = express()
|
|
81
|
+
// CloudFront → ALB → Node: hop count so `req.ip` is the viewer (#769).
|
|
82
|
+
// Default 2. Avoid `true` (leftmost XFF — spoofable). Override via OSSY_TRUST_PROXY_HOPS.
|
|
83
|
+
{
|
|
84
|
+
const raw = process.env.OSSY_TRUST_PROXY_HOPS
|
|
85
|
+
if (raw != null && String(raw).trim() !== '') {
|
|
86
|
+
const hops = Number(raw)
|
|
87
|
+
app.set('trust proxy', Number.isFinite(hops) && hops >= 0 ? hops : 2)
|
|
88
|
+
} else {
|
|
89
|
+
app.set('trust proxy', 2)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
81
92
|
// Liveness for ALB/ECS — before site loading so probes never depend on CMS/domain.
|
|
82
93
|
// Prefer OSSY_SERVICE_NAME (ECS task env) when set.
|
|
83
94
|
mountHealthEndpoint(app)
|
|
@@ -97,7 +108,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
97
108
|
next()
|
|
98
109
|
})
|
|
99
110
|
|
|
100
|
-
// Proxy /@ossy/*
|
|
111
|
+
// Proxy leftover /@ossy/* onto this process (/api/v0, /actions) unless OSSY_API_URL is absolute.
|
|
101
112
|
app.use(ProxyInternal())
|
|
102
113
|
|
|
103
114
|
// Force a fresh site download without waiting for the TTL to expire
|
package/src/server.js
CHANGED
|
@@ -43,8 +43,11 @@ import {
|
|
|
43
43
|
import {
|
|
44
44
|
clearAuthCookie,
|
|
45
45
|
clearWorkspaceFromUserAppSettings,
|
|
46
|
+
mergeUserAppSettingsCookie,
|
|
46
47
|
readWorkspaceIdFromCookies,
|
|
48
|
+
setAuthCookie,
|
|
47
49
|
} from './user-app-settings.js'
|
|
50
|
+
import { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
48
51
|
const log = createLogger('@ossy/platform')
|
|
49
52
|
const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
|
|
50
53
|
const SSR_RENDER_TIMEOUT_MS = resolveTimeoutMs('OSSY_SSR_RENDER_TIMEOUT_MS', 30_000)
|
|
@@ -323,6 +326,17 @@ export async function startServer (options = {}) {
|
|
|
323
326
|
}
|
|
324
327
|
|
|
325
328
|
const app = express()
|
|
329
|
+
// CloudFront → ALB → Node: hop count so `req.ip` is the viewer (#769).
|
|
330
|
+
// Default 2. Avoid `true` (leftmost XFF — spoofable). Override via OSSY_TRUST_PROXY_HOPS.
|
|
331
|
+
{
|
|
332
|
+
const raw = process.env.OSSY_TRUST_PROXY_HOPS
|
|
333
|
+
if (raw != null && String(raw).trim() !== '') {
|
|
334
|
+
const hops = Number(raw)
|
|
335
|
+
app.set('trust proxy', Number.isFinite(hops) && hops >= 0 ? hops : 2)
|
|
336
|
+
} else {
|
|
337
|
+
app.set('trust proxy', 2)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
326
340
|
// Liveness for ALB/ECS — before auth so probes never depend on cookies/Mongo.
|
|
327
341
|
// Prefer OSSY_SERVICE_NAME (ECS task env) when set.
|
|
328
342
|
mountHealthEndpoint(app)
|
|
@@ -427,6 +441,9 @@ export async function startServer (options = {}) {
|
|
|
427
441
|
clearAuthCookie(res)
|
|
428
442
|
clearWorkspaceFromUserAppSettings(req, res)
|
|
429
443
|
}
|
|
444
|
+
if (actionId === '@ossy/authentication/actions/verify-sign-in' && result?.token) {
|
|
445
|
+
await applyVerifySignInSessionCookies(req, res, result)
|
|
446
|
+
}
|
|
430
447
|
res.json(result ?? { ok: true })
|
|
431
448
|
} catch (err) {
|
|
432
449
|
// Still clear auth on sign-out failures so a broken session can recover.
|
|
@@ -670,6 +687,7 @@ export default startServer
|
|
|
670
687
|
export { loadLayoutsById, resolvePageLayoutRender }
|
|
671
688
|
export { ConfigService } from './config.service.js'
|
|
672
689
|
export { ActionService } from './actions/action.service.js'
|
|
690
|
+
export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
673
691
|
export { IntegrationService } from './integration.service.js'
|
|
674
692
|
export { StorageClient } from './storage/storage.client.js'
|
|
675
693
|
export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
|
package/src/site-loader.js
CHANGED
|
@@ -10,7 +10,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
|
10
10
|
const DOWNLOAD_CONCURRENCY = 10
|
|
11
11
|
const BASE_DIR = path.join(os.tmpdir(), 'ossy-rt')
|
|
12
12
|
|
|
13
|
-
const API_URL = process.env.OSSY_API_URL || 'https://
|
|
13
|
+
const API_URL = process.env.OSSY_API_URL || 'https://ossy.se/api/v0'
|
|
14
14
|
const API_KEY = process.env.OSSY_API_KEY
|
|
15
15
|
|
|
16
16
|
/** @type {Map<string, { buildDir: string, workspaceId: string, loadedAt: number }>} */
|
|
@@ -4,6 +4,7 @@ import { IntegrationService } from '../integration.service.js'
|
|
|
4
4
|
import { createLogger } from '@ossy/observability'
|
|
5
5
|
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
6
6
|
import { TaskRunService } from '../audit/task-run-service.js'
|
|
7
|
+
import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
|
|
7
8
|
|
|
8
9
|
const SCHEDULER_INTERVAL_MS = 60_000
|
|
9
10
|
const _serviceLog = createLogger('TaskService')
|
|
@@ -108,18 +109,20 @@ export class TaskService {
|
|
|
108
109
|
log: context.log ?? createLogger(id),
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
return runWithEventContext({ ip: resolveClientIp(context.req) }, () =>
|
|
113
|
+
TaskRunService.execute({
|
|
114
|
+
taskId: id,
|
|
115
|
+
handler: task.handler,
|
|
116
|
+
context: invokeContext,
|
|
117
|
+
trigger: 'invoke',
|
|
118
|
+
triggeredBy: {
|
|
119
|
+
channel: context.req ? undefined : 'api',
|
|
120
|
+
actionInvocationId: context.actionInvocationId ?? null,
|
|
121
|
+
},
|
|
122
|
+
executionEnv: 'ossy_server',
|
|
123
|
+
audit,
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/** @param {string} id */
|
package/src/test/flow-runner.js
CHANGED
|
@@ -26,6 +26,7 @@ import { assertDownloadFilenames, resolveDownloadTarget } from './flow-download.
|
|
|
26
26
|
import { resolveFilesTarget } from './flow-files.js'
|
|
27
27
|
import { resolvePressKey } from './flow-press.js'
|
|
28
28
|
import { resolveViewportSize } from './flow-viewport.js'
|
|
29
|
+
import { flowBelongsToShard, parseFlowShard, sortFlowsForShard } from './flow-shard.js'
|
|
29
30
|
|
|
30
31
|
export {
|
|
31
32
|
applyFormFieldOverrides,
|
|
@@ -39,6 +40,12 @@ export {
|
|
|
39
40
|
resolveActionMatchers,
|
|
40
41
|
resolveActionService,
|
|
41
42
|
} from './flow-action.js'
|
|
43
|
+
export {
|
|
44
|
+
flowBelongsToShard,
|
|
45
|
+
flowIdsForShard,
|
|
46
|
+
parseFlowShard,
|
|
47
|
+
sortFlowsForShard,
|
|
48
|
+
} from './flow-shard.js'
|
|
42
49
|
export { resolveClickTarget } from './flow-click.js'
|
|
43
50
|
export {
|
|
44
51
|
assertDownloadFilenames,
|
|
@@ -405,13 +412,13 @@ export async function runFlow (flow, options = {}) {
|
|
|
405
412
|
const selector = actionSelector(actionId, matchers)
|
|
406
413
|
const actionTimeout = step.timeout ?? 15000
|
|
407
414
|
const actionDeadline = Date.now() + actionTimeout
|
|
408
|
-
// SSR markup is visible before React attaches onClick. Wait
|
|
409
|
-
//
|
|
410
|
-
//
|
|
415
|
+
// SSR markup is visible before React attaches onClick. Wait for visibility,
|
|
416
|
+
// then click: form submits use remount-safe evaluate + requestSubmit; other
|
|
417
|
+
// CTAs use Playwright click (programmatic el.click() often skips React onClick).
|
|
411
418
|
//
|
|
412
|
-
// Re-query the live node after
|
|
413
|
-
//
|
|
414
|
-
//
|
|
419
|
+
// Re-query the live node after settle: concurrent auth/workspace fetches can
|
|
420
|
+
// remount the tree and detach Playwright locators mid-scroll (and wipe
|
|
421
|
+
// uncontrolled form state). Retry within the action timeout.
|
|
415
422
|
// Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
|
|
416
423
|
let clicked = false
|
|
417
424
|
let lastError
|
|
@@ -432,52 +439,63 @@ export async function runFlow (flow, options = {}) {
|
|
|
432
439
|
state: 'visible',
|
|
433
440
|
timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
|
|
434
441
|
})
|
|
435
|
-
// Scroll inside evaluate via a fresh querySelector — Playwright's
|
|
436
|
-
// scrollIntoViewIfNeeded holds a locator through a stability wait and
|
|
437
|
-
// throws "not attached" when GetWorkspace remounts PackageServiceToggle.
|
|
438
442
|
const fieldRestore = { ...(context.fields ?? {}) }
|
|
439
443
|
if (context.email != null && fieldRestore.email == null) {
|
|
440
444
|
fieldRestore.email = context.email
|
|
441
445
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
desc?.set?.call(input, asText)
|
|
465
|
-
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
466
|
-
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
446
|
+
// Form submits: restore fills + requestSubmit in one evaluate (remount-safe).
|
|
447
|
+
// Other CTAs (Enable, Publish, OpenExport): Playwright click — programmatic
|
|
448
|
+
// el.click() often never invokes React onClick (no /actions POST; publish stays off).
|
|
449
|
+
const isSubmitButton = await candidate.evaluate((el) => {
|
|
450
|
+
const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
|
|
451
|
+
? el.form
|
|
452
|
+
: el.closest?.('form')
|
|
453
|
+
if (!form) return false
|
|
454
|
+
return (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
|
|
455
|
+
? el.type === 'submit'
|
|
456
|
+
: el.getAttribute?.('type') === 'submit'
|
|
457
|
+
})
|
|
458
|
+
if (isSubmitButton) {
|
|
459
|
+
await page.evaluate(async ({ selector: sel, fields }) => {
|
|
460
|
+
await new Promise((resolve) => {
|
|
461
|
+
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
|
462
|
+
})
|
|
463
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
464
|
+
const el = document.querySelector(sel)
|
|
465
|
+
if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
|
|
466
|
+
if (typeof el.scrollIntoView === 'function') {
|
|
467
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
467
468
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
469
|
+
const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
|
|
470
|
+
? el.form
|
|
471
|
+
: el.closest?.('form')
|
|
472
|
+
if (form && fields && typeof fields === 'object') {
|
|
473
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
474
|
+
if (value == null || typeof value === 'object') continue
|
|
475
|
+
const input = form.querySelector(`[name="${name}"]`)
|
|
476
|
+
if (!input || input.disabled) continue
|
|
477
|
+
if (input instanceof HTMLInputElement && input.type === 'file') continue
|
|
478
|
+
const asText = String(value)
|
|
479
|
+
if (input.value === asText) continue
|
|
480
|
+
const proto = Object.getPrototypeOf(input)
|
|
481
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
|
|
482
|
+
desc?.set?.call(input, asText)
|
|
483
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
484
|
+
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (form && typeof form.requestSubmit === 'function') {
|
|
488
|
+
form.requestSubmit(el)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if (typeof el.click === 'function') el.click()
|
|
492
|
+
}, { selector, fields: fieldRestore })
|
|
493
|
+
} else {
|
|
494
|
+
await candidate.scrollIntoViewIfNeeded().catch(() => {})
|
|
495
|
+
await candidate.click({
|
|
496
|
+
timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
|
|
497
|
+
})
|
|
498
|
+
}
|
|
481
499
|
clicked = true
|
|
482
500
|
break
|
|
483
501
|
} catch (err) {
|
|
@@ -538,6 +556,42 @@ export async function runFlow (flow, options = {}) {
|
|
|
538
556
|
continue
|
|
539
557
|
}
|
|
540
558
|
|
|
559
|
+
if (step.inputs != null) {
|
|
560
|
+
if (!page) throw new Error('inputs step requires a Playwright page')
|
|
561
|
+
const inputsTimeout = step.timeout ?? 15000
|
|
562
|
+
for (const [selector, valueSpec] of Object.entries(step.inputs)) {
|
|
563
|
+
const value = resolveContextValue(valueSpec, context)
|
|
564
|
+
if (value == null) {
|
|
565
|
+
throw new Error(`inputs step: no value resolved for selector "${selector}"`)
|
|
566
|
+
}
|
|
567
|
+
const resolvedSelector = interpolateContextString(selector, context, {
|
|
568
|
+
escape: escapeCssAttrValue,
|
|
569
|
+
})
|
|
570
|
+
const locator = page.locator(resolvedSelector).first()
|
|
571
|
+
await locator.waitFor({ state: 'visible', timeout: inputsTimeout })
|
|
572
|
+
const asText = String(value)
|
|
573
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
574
|
+
await locator.fill(asText)
|
|
575
|
+
await locator.blur().catch(() => {})
|
|
576
|
+
try {
|
|
577
|
+
await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
|
|
578
|
+
break
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (attempt === 2) throw err
|
|
581
|
+
await page.waitForTimeout(150)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Remount-safe action submits restore from context.fields by input name.
|
|
585
|
+
// Keep ad-hoc fills (e.g. sign-in code) in that map so settle remounts
|
|
586
|
+
// do not wipe values before requestSubmit.
|
|
587
|
+
const name = await locator.getAttribute('name')
|
|
588
|
+
if (name) {
|
|
589
|
+
context.fields = { ...(context.fields ?? {}), [name]: asText }
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
|
|
541
595
|
if (step.capture != null) {
|
|
542
596
|
if (!page) throw new Error('capture step requires a Playwright page')
|
|
543
597
|
for (const [key, spec] of Object.entries(step.capture)) {
|
|
@@ -567,37 +621,109 @@ export async function runFlow (flow, options = {}) {
|
|
|
567
621
|
const to = resolveContextValue(emailStep.to ?? '$email', context)
|
|
568
622
|
const templateId = emailStep.id ?? emailStep.templateId
|
|
569
623
|
const clickLabel = emailStep.click ?? emailStep.link
|
|
624
|
+
const extract = emailStep.extract
|
|
570
625
|
if (!to) throw new Error('email step requires "to" (recipient email)')
|
|
571
626
|
if (!templateId) throw new Error('email step requires "id" (email template id)')
|
|
572
|
-
if (!clickLabel
|
|
627
|
+
if (!clickLabel && !extract) {
|
|
628
|
+
throw new Error('email step requires "click" or "extract"')
|
|
629
|
+
}
|
|
573
630
|
|
|
574
631
|
const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
|
|
575
632
|
const params = new URLSearchParams({ to: String(to), template: String(templateId) })
|
|
576
633
|
const inboxUrl = `${baseURL}${inboxPath}?${params}`
|
|
577
|
-
const pattern = linkNamePattern(clickLabel)
|
|
578
634
|
const deadline = Date.now() + (emailStep.timeout ?? 20000)
|
|
579
635
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
636
|
+
const openInboxBody = async (inboxPage) => {
|
|
637
|
+
await inboxPage.goto(inboxUrl)
|
|
638
|
+
const body = inboxPage.locator('[data-email-body]').first()
|
|
639
|
+
await body.waitFor({ state: 'visible', timeout: 2000 })
|
|
640
|
+
return body
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Extract-only must not navigate the flow page — SPA React state (e.g. SignIn
|
|
644
|
+
// success + code form for PWA) is lost on goto/goBack remount.
|
|
645
|
+
if (extract && !clickLabel) {
|
|
646
|
+
const inboxPage = await page.context().newPage()
|
|
647
|
+
let extracted = false
|
|
587
648
|
try {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
649
|
+
while (Date.now() < deadline && !extracted) {
|
|
650
|
+
try {
|
|
651
|
+
const body = await openInboxBody(inboxPage)
|
|
652
|
+
for (const [key, spec] of Object.entries(extract)) {
|
|
653
|
+
const selector = typeof spec === 'string' ? spec : spec.selector
|
|
654
|
+
const attr = typeof spec === 'object' ? spec.attr : undefined
|
|
655
|
+
const resolvedSelector = interpolateContextString(selector, context, {
|
|
656
|
+
escape: escapeCssAttrValue,
|
|
657
|
+
})
|
|
658
|
+
const locator = body.locator(resolvedSelector).first()
|
|
659
|
+
await locator.waitFor({ state: 'attached', timeout: 2000 })
|
|
660
|
+
const value = attr
|
|
661
|
+
? await locator.getAttribute(attr)
|
|
662
|
+
: await locator.textContent()
|
|
663
|
+
context[key] = typeof value === 'string' ? value.trim() : value
|
|
664
|
+
}
|
|
665
|
+
extracted = true
|
|
666
|
+
} catch {
|
|
667
|
+
await page.waitForTimeout(500)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} finally {
|
|
671
|
+
await inboxPage.close().catch(() => {})
|
|
672
|
+
}
|
|
673
|
+
if (!extracted) {
|
|
674
|
+
throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
|
|
597
675
|
}
|
|
676
|
+
continue
|
|
598
677
|
}
|
|
599
|
-
|
|
600
|
-
|
|
678
|
+
|
|
679
|
+
if (extract && clickLabel) {
|
|
680
|
+
let extracted = false
|
|
681
|
+
while (Date.now() < deadline && !extracted) {
|
|
682
|
+
try {
|
|
683
|
+
const body = await openInboxBody(page)
|
|
684
|
+
for (const [key, spec] of Object.entries(extract)) {
|
|
685
|
+
const selector = typeof spec === 'string' ? spec : spec.selector
|
|
686
|
+
const attr = typeof spec === 'object' ? spec.attr : undefined
|
|
687
|
+
const resolvedSelector = interpolateContextString(selector, context, {
|
|
688
|
+
escape: escapeCssAttrValue,
|
|
689
|
+
})
|
|
690
|
+
const locator = body.locator(resolvedSelector).first()
|
|
691
|
+
await locator.waitFor({ state: 'attached', timeout: 2000 })
|
|
692
|
+
const value = attr
|
|
693
|
+
? await locator.getAttribute(attr)
|
|
694
|
+
: await locator.textContent()
|
|
695
|
+
context[key] = typeof value === 'string' ? value.trim() : value
|
|
696
|
+
}
|
|
697
|
+
extracted = true
|
|
698
|
+
} catch {
|
|
699
|
+
await page.waitForTimeout(500)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (!extracted) {
|
|
703
|
+
throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (clickLabel) {
|
|
708
|
+
const pattern = linkNamePattern(clickLabel)
|
|
709
|
+
let clicked = false
|
|
710
|
+
while (Date.now() < deadline && !clicked) {
|
|
711
|
+
const body = await openInboxBody(page)
|
|
712
|
+
const link = body.getByRole('link', { name: pattern }).first()
|
|
713
|
+
try {
|
|
714
|
+
await link.waitFor({ state: 'visible', timeout: 2000 })
|
|
715
|
+
await Promise.all([
|
|
716
|
+
page.waitForLoadState('domcontentloaded'),
|
|
717
|
+
link.click(),
|
|
718
|
+
])
|
|
719
|
+
clicked = true
|
|
720
|
+
} catch {
|
|
721
|
+
await page.waitForTimeout(500)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (!clicked) {
|
|
725
|
+
throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
|
|
726
|
+
}
|
|
601
727
|
}
|
|
602
728
|
continue
|
|
603
729
|
}
|
|
@@ -721,17 +847,32 @@ export function registerFlow (mod, options = {}) {
|
|
|
721
847
|
/**
|
|
722
848
|
* Register all flows listed in `manifest.flows[]`.
|
|
723
849
|
* @param {string} manifestPath Absolute path to build/manifest.json
|
|
850
|
+
* @param {{ shard?: string }} [options] Optional shard override (`N/M`); defaults to `E2E_FLOW_SHARD`
|
|
724
851
|
*/
|
|
725
|
-
export async function registerFlowsFromManifest (manifestPath) {
|
|
852
|
+
export async function registerFlowsFromManifest (manifestPath, options = {}) {
|
|
726
853
|
const manifest = loadManifest(manifestPath)
|
|
727
854
|
const buildDir = path.dirname(manifestPath)
|
|
728
855
|
const staticDir = path.join(buildDir, 'public', 'static')
|
|
729
|
-
|
|
730
|
-
|
|
856
|
+
const shard = parseFlowShard(options.shard ?? process.env.E2E_FLOW_SHARD)
|
|
857
|
+
// Sort by stable flow id so independently built matrix jobs partition identically
|
|
858
|
+
// even when package discovery/`readdir` order differs (#768 / #770).
|
|
859
|
+
const flows = sortFlowsForShard(manifest.flows ?? [])
|
|
860
|
+
let registered = 0
|
|
861
|
+
|
|
862
|
+
for (let i = 0; i < flows.length; i++) {
|
|
863
|
+
if (!flowBelongsToShard(i, shard)) continue
|
|
864
|
+
const entry = flows[i]
|
|
731
865
|
const chunkName = entry.entry.replace(/^\/static\//, '')
|
|
732
866
|
const chunkPath = path.join(staticDir, chunkName)
|
|
733
867
|
const mod = await import(pathToFileURL(chunkPath).href + `?ts=${Date.now()}`)
|
|
734
868
|
registerFlow(mod, { manifestPath, locale: manifest.config?.defaultLanguage })
|
|
869
|
+
registered += 1
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (shard) {
|
|
873
|
+
console.log(
|
|
874
|
+
`[e2e] flow shard ${shard.index}/${shard.total}: registered ${registered}/${flows.length} flows`,
|
|
875
|
+
)
|
|
735
876
|
}
|
|
736
877
|
}
|
|
737
878
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI flow partitioning helpers.
|
|
3
|
+
*
|
|
4
|
+
* All product flows register from one Playwright `runner.spec.js`, so Playwright's
|
|
5
|
+
* file-level `--shard` leaves shards 2..N empty. Matrix jobs set `E2E_FLOW_SHARD=N/M`
|
|
6
|
+
* and the flow runner registers only that slice.
|
|
7
|
+
*
|
|
8
|
+
* Membership is by **stable flow id order**, not discovery/`readdir` order — each
|
|
9
|
+
* matrix job builds independently (#768 / #770).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse `E2E_FLOW_SHARD` / explicit shard string (`1/4`) into 1-based index + total.
|
|
14
|
+
*
|
|
15
|
+
* @param {string | undefined | null} value
|
|
16
|
+
* @returns {{ index: number, total: number } | null}
|
|
17
|
+
*/
|
|
18
|
+
export function parseFlowShard (value = process.env.E2E_FLOW_SHARD) {
|
|
19
|
+
if (value == null || value === '') return null
|
|
20
|
+
const match = String(value).trim().match(/^(\d+)\s*\/\s*(\d+)$/)
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new Error(`Invalid E2E_FLOW_SHARD "${value}" (expected N/M, e.g. 1/4)`)
|
|
23
|
+
}
|
|
24
|
+
const index = Number(match[1])
|
|
25
|
+
const total = Number(match[2])
|
|
26
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1 || index < 1 || index > total) {
|
|
27
|
+
throw new Error(`Invalid E2E_FLOW_SHARD "${value}" (index must be 1..total)`)
|
|
28
|
+
}
|
|
29
|
+
return { index, total }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic order for shard partitioning (localeCompare on flow id).
|
|
34
|
+
*
|
|
35
|
+
* @param {Array<{ id?: string }>} flows
|
|
36
|
+
* @returns {Array<{ id?: string }>}
|
|
37
|
+
*/
|
|
38
|
+
export function sortFlowsForShard (flows) {
|
|
39
|
+
return [...(flows ?? [])].sort((a, b) =>
|
|
40
|
+
String(a?.id ?? '').localeCompare(String(b?.id ?? '')),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stable round-robin membership for CI flow matrix jobs.
|
|
46
|
+
* @param {number} flowIndex 0-based position in the **id-sorted** flow list
|
|
47
|
+
* @param {{ index: number, total: number } | null} shard
|
|
48
|
+
*/
|
|
49
|
+
export function flowBelongsToShard (flowIndex, shard) {
|
|
50
|
+
if (!shard) return true
|
|
51
|
+
return flowIndex % shard.total === shard.index - 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flow ids owned by a shard for a given (unordered) manifest list.
|
|
56
|
+
*
|
|
57
|
+
* @param {Array<{ id?: string }>} flows
|
|
58
|
+
* @param {{ index: number, total: number } | null} shard
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function flowIdsForShard (flows, shard) {
|
|
62
|
+
const ordered = sortFlowsForShard(flows)
|
|
63
|
+
return ordered
|
|
64
|
+
.filter((_, i) => flowBelongsToShard(i, shard))
|
|
65
|
+
.map((flow) => String(flow.id ?? ''))
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
flowBelongsToShard,
|
|
4
|
+
flowIdsForShard,
|
|
5
|
+
parseFlowShard,
|
|
6
|
+
sortFlowsForShard,
|
|
7
|
+
} from './flow-shard.js'
|
|
8
|
+
|
|
9
|
+
describe('parseFlowShard', () => {
|
|
10
|
+
it('returns null for empty values', () => {
|
|
11
|
+
expect(parseFlowShard(undefined)).toBeNull()
|
|
12
|
+
expect(parseFlowShard(null)).toBeNull()
|
|
13
|
+
expect(parseFlowShard('')).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses N/M', () => {
|
|
17
|
+
expect(parseFlowShard('1/4')).toEqual({ index: 1, total: 4 })
|
|
18
|
+
expect(parseFlowShard(' 3 / 4 ')).toEqual({ index: 3, total: 4 })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('rejects invalid shapes', () => {
|
|
22
|
+
expect(() => parseFlowShard('4')).toThrow(/Invalid E2E_FLOW_SHARD/)
|
|
23
|
+
expect(() => parseFlowShard('0/4')).toThrow(/Invalid E2E_FLOW_SHARD/)
|
|
24
|
+
expect(() => parseFlowShard('5/4')).toThrow(/Invalid E2E_FLOW_SHARD/)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('flowBelongsToShard', () => {
|
|
29
|
+
it('keeps every flow when shard is null', () => {
|
|
30
|
+
expect(flowBelongsToShard(0, null)).toBe(true)
|
|
31
|
+
expect(flowBelongsToShard(7, null)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('round-robins indices across shards', () => {
|
|
35
|
+
const shard1 = { index: 1, total: 4 }
|
|
36
|
+
const shard2 = { index: 2, total: 4 }
|
|
37
|
+
const owned = [...Array(8).keys()].filter(i => flowBelongsToShard(i, shard1))
|
|
38
|
+
const owned2 = [...Array(8).keys()].filter(i => flowBelongsToShard(i, shard2))
|
|
39
|
+
expect(owned).toEqual([0, 4])
|
|
40
|
+
expect(owned2).toEqual([1, 5])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('sortFlowsForShard / flowIdsForShard', () => {
|
|
45
|
+
it('sorts by stable id regardless of input order', () => {
|
|
46
|
+
const a = [{ id: 'zeta' }, { id: 'alpha' }, { id: 'mu' }]
|
|
47
|
+
const b = [{ id: 'mu' }, { id: 'zeta' }, { id: 'alpha' }]
|
|
48
|
+
expect(sortFlowsForShard(a).map(f => f.id)).toEqual(['alpha', 'mu', 'zeta'])
|
|
49
|
+
expect(sortFlowsForShard(b).map(f => f.id)).toEqual(['alpha', 'mu', 'zeta'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('assigns the same ids to shard 1/4 for two different input orders', () => {
|
|
53
|
+
const shard = { index: 1, total: 4 }
|
|
54
|
+
const orderA = [
|
|
55
|
+
{ id: '@ossy/z/flows/c' },
|
|
56
|
+
{ id: '@ossy/a/flows/x' },
|
|
57
|
+
{ id: '@ossy/m/flows/y' },
|
|
58
|
+
{ id: '@ossy/b/flows/w' },
|
|
59
|
+
{ id: '@ossy/n/flows/v' },
|
|
60
|
+
{ id: '@ossy/c/flows/u' },
|
|
61
|
+
{ id: '@ossy/d/flows/t' },
|
|
62
|
+
{ id: '@ossy/e/flows/s' },
|
|
63
|
+
]
|
|
64
|
+
const orderB = [...orderA].reverse()
|
|
65
|
+
|
|
66
|
+
expect(flowIdsForShard(orderA, shard)).toEqual(flowIdsForShard(orderB, shard))
|
|
67
|
+
// id-sorted: a/x, b/w, c/u, d/t, e/s, m/y, n/v, z/c → shard 1 owns indices 0,4
|
|
68
|
+
expect(flowIdsForShard(orderA, shard)).toEqual([
|
|
69
|
+
'@ossy/a/flows/x',
|
|
70
|
+
'@ossy/e/flows/s',
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
})
|
package/src/test/test.util.js
CHANGED
|
@@ -173,6 +173,43 @@ export class TestUtil {
|
|
|
173
173
|
return ev.payload.token
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Resolve a six-digit sign-in code from its stored HMAC digest (integration tests only).
|
|
178
|
+
*
|
|
179
|
+
* @param {string} subjectId
|
|
180
|
+
* @returns {Promise<string>}
|
|
181
|
+
*/
|
|
182
|
+
static async getLatestVerificationCodeForSubject(subjectId) {
|
|
183
|
+
const ev = await EventStore.Collection.findOne(
|
|
184
|
+
{
|
|
185
|
+
type: TOKEN_SCHEMA,
|
|
186
|
+
event: 'Created',
|
|
187
|
+
'payload.type': 'Verification',
|
|
188
|
+
'payload.subject': subjectId,
|
|
189
|
+
},
|
|
190
|
+
{ sort: { created: -1 } },
|
|
191
|
+
)
|
|
192
|
+
const codeHash = ev?.payload?.codeHash
|
|
193
|
+
if (!codeHash) {
|
|
194
|
+
return Promise.reject(new Error('No verification code hash found for subject'))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const secret = process.env.TOKEN_SECRET
|
|
198
|
+
if (!secret) {
|
|
199
|
+
return Promise.reject(new Error('TOKEN_SECRET is required to resolve verification codes in tests'))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { createHmac } = await import('node:crypto')
|
|
203
|
+
const hashCode = (code) => createHmac('sha256', secret).update(code).digest('hex')
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < 1_000_000; i += 1) {
|
|
206
|
+
const code = String(i).padStart(6, '0')
|
|
207
|
+
if (hashCode(code) === codeHash) return code
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return Promise.reject(new Error('Could not resolve verification code from hash'))
|
|
211
|
+
}
|
|
212
|
+
|
|
176
213
|
static async getLatestEmailChangeJwtForSubject(subjectId) {
|
|
177
214
|
const ev = await EventStore.Collection.findOne(
|
|
178
215
|
{
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ActionService } from './actions/action.service.js'
|
|
2
|
+
import { mergeUserAppSettingsCookie, setAuthCookie } from './user-app-settings.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Set auth (and optional single-workspace default) cookies after verify-sign-in.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('express').Request} req
|
|
8
|
+
* @param {import('express').Response} res
|
|
9
|
+
* @param {{ sub: string, token: string }} session
|
|
10
|
+
*/
|
|
11
|
+
export async function applyVerifySignInSessionCookies (req, res, { sub, token }) {
|
|
12
|
+
setAuthCookie(res, token)
|
|
13
|
+
try {
|
|
14
|
+
const workspaces = await ActionService.invoke('@ossy/workspaces/actions/list', {
|
|
15
|
+
payload: { userId: sub },
|
|
16
|
+
req,
|
|
17
|
+
})
|
|
18
|
+
if (workspaces.length === 1) {
|
|
19
|
+
mergeUserAppSettingsCookie(req, res, { workspaceId: workspaces[0].id })
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Cookie for the session is enough; workspace default is best-effort.
|
|
23
|
+
}
|
|
24
|
+
}
|