@owlmeans/client-auth 0.1.18-rc.8 → 0.1.18
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/README.md +283 -37
- package/agent-meta/manifest.json +50 -4
- package/agent-meta/skills/client-auth/SKILL.md +85 -23
- package/agent-meta/skills/client-auth/parts/login.md +77 -0
- package/agent-meta/skills/client-auth/parts/manager.md +37 -0
- package/build/components/dispatcher/component.d.ts.map +1 -1
- package/build/components/dispatcher/component.js +26 -6
- package/build/components/dispatcher/component.js.map +1 -1
- package/build/components/dispatcher/types.local.d.ts +7 -0
- package/build/components/dispatcher/types.local.d.ts.map +1 -0
- package/build/components/dispatcher/types.local.js +2 -0
- package/build/components/dispatcher/types.local.js.map +1 -0
- package/build/entrypoints.d.ts +7 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +7 -0
- package/build/entrypoints.js.map +1 -0
- package/build/helper.d.ts +2 -1
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +28 -5
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -1
- package/build/index.js.map +1 -1
- package/build/login/adopt/types.d.ts +26 -0
- package/build/login/adopt/types.d.ts.map +1 -0
- package/build/login/adopt/types.js +2 -0
- package/build/login/adopt/types.js.map +1 -0
- package/build/login/adopt.d.ts +7 -12
- package/build/login/adopt.d.ts.map +1 -1
- package/build/login/adopt.js +15 -14
- package/build/login/adopt.js.map +1 -1
- package/build/login/consts.d.ts +89 -4
- package/build/login/consts.d.ts.map +1 -1
- package/build/login/consts.js +93 -5
- package/build/login/consts.js.map +1 -1
- package/build/login/consts.local.d.ts +16 -0
- package/build/login/consts.local.d.ts.map +1 -0
- package/build/login/consts.local.js +30 -0
- package/build/login/consts.local.js.map +1 -0
- package/build/login/credit/types.d.ts +7 -0
- package/build/login/credit/types.d.ts.map +1 -0
- package/build/login/credit/types.js +2 -0
- package/build/login/credit/types.js.map +1 -0
- package/build/login/credit.d.ts +19 -0
- package/build/login/credit.d.ts.map +1 -0
- package/build/login/credit.js +58 -0
- package/build/login/credit.js.map +1 -0
- package/build/login/env/types.d.ts +35 -0
- package/build/login/env/types.d.ts.map +1 -0
- package/build/login/env/types.js +2 -0
- package/build/login/env/types.js.map +1 -0
- package/build/login/env.d.ts +5 -29
- package/build/login/env.d.ts.map +1 -1
- package/build/login/env.js +63 -80
- package/build/login/env.js.map +1 -1
- package/build/login/flow.d.ts +20 -0
- package/build/login/flow.d.ts.map +1 -0
- package/build/login/flow.js +39 -0
- package/build/login/flow.js.map +1 -0
- package/build/login/hook.d.ts +30 -4
- package/build/login/hook.d.ts.map +1 -1
- package/build/login/hook.js +53 -18
- package/build/login/hook.js.map +1 -1
- package/build/login/i18n/be.json +41 -0
- package/build/login/i18n/de.json +41 -0
- package/build/login/i18n/en.json +41 -0
- package/build/login/i18n/es.json +41 -0
- package/build/login/i18n/fr.json +41 -0
- package/build/login/i18n/pl.json +41 -0
- package/build/login/i18n/ru.json +41 -0
- package/build/login/i18n/uk.json +41 -0
- package/build/login/i18n.d.ts +2 -0
- package/build/login/i18n.d.ts.map +1 -0
- package/build/login/i18n.js +27 -0
- package/build/login/i18n.js.map +1 -0
- package/build/login/index.d.ts +20 -0
- package/build/login/index.d.ts.map +1 -1
- package/build/login/index.js +11 -0
- package/build/login/index.js.map +1 -1
- package/build/login/land/types.d.ts +52 -0
- package/build/login/land/types.d.ts.map +1 -0
- package/build/login/land/types.js +2 -0
- package/build/login/land/types.js.map +1 -0
- package/build/login/land.d.ts +6 -0
- package/build/login/land.d.ts.map +1 -0
- package/build/login/land.js +122 -0
- package/build/login/land.js.map +1 -0
- package/build/login/methods/types.d.ts +30 -0
- package/build/login/methods/types.d.ts.map +1 -0
- package/build/login/methods/types.js +2 -0
- package/build/login/methods/types.js.map +1 -0
- package/build/login/methods.d.ts +7 -0
- package/build/login/methods.d.ts.map +1 -0
- package/build/login/methods.js +87 -0
- package/build/login/methods.js.map +1 -0
- package/build/login/resume/types.d.ts +24 -0
- package/build/login/resume/types.d.ts.map +1 -0
- package/build/login/resume/types.js +2 -0
- package/build/login/resume/types.js.map +1 -0
- package/build/login/resume.d.ts +9 -0
- package/build/login/resume.d.ts.map +1 -0
- package/build/login/resume.js +38 -0
- package/build/login/resume.js.map +1 -0
- package/build/login/screen.d.ts +17 -0
- package/build/login/screen.d.ts.map +1 -0
- package/build/login/screen.js +66 -0
- package/build/login/screen.js.map +1 -0
- package/build/login/service.d.ts +6 -5
- package/build/login/service.d.ts.map +1 -1
- package/build/login/service.js +141 -15
- package/build/login/service.js.map +1 -1
- package/build/login/start/types.d.ts +29 -0
- package/build/login/start/types.d.ts.map +1 -0
- package/build/login/start/types.js +2 -0
- package/build/login/start/types.js.map +1 -0
- package/build/login/start.d.ts +5 -0
- package/build/login/start.d.ts.map +1 -0
- package/build/login/start.js +36 -0
- package/build/login/start.js.map +1 -0
- package/build/login/surrogate/types.d.ts +9 -0
- package/build/login/surrogate/types.d.ts.map +1 -0
- package/build/login/surrogate/types.js +2 -0
- package/build/login/surrogate/types.js.map +1 -0
- package/build/login/surrogate.d.ts +13 -0
- package/build/login/surrogate.d.ts.map +1 -0
- package/build/login/surrogate.js +33 -0
- package/build/login/surrogate.js.map +1 -0
- package/build/login/terms/types.d.ts +112 -0
- package/build/login/terms/types.d.ts.map +1 -0
- package/build/login/terms/types.js +2 -0
- package/build/login/terms/types.js.map +1 -0
- package/build/login/terms-config.d.ts +65 -0
- package/build/login/terms-config.d.ts.map +1 -0
- package/build/login/terms-config.js +2 -0
- package/build/login/terms-config.js.map +1 -0
- package/build/login/terms.d.ts +21 -0
- package/build/login/terms.d.ts.map +1 -0
- package/build/login/terms.js +235 -0
- package/build/login/terms.js.map +1 -0
- package/build/login/types.d.ts +223 -16
- package/build/login/types.d.ts.map +1 -1
- package/build/login/types.js +1 -16
- package/build/login/types.js.map +1 -1
- package/build/manager/components/authentication/control.d.ts.map +1 -1
- package/build/manager/components/authentication/control.js +3 -3
- package/build/manager/components/authentication/control.js.map +1 -1
- package/build/manager/components/authentication/types.d.ts +2 -2
- package/build/manager/components/authentication/types.d.ts.map +1 -1
- package/build/manager/components/tunnel-consumer.js +2 -2
- package/build/manager/components/tunnel-consumer.js.map +1 -1
- package/build/manager/entrypoints.d.ts +11 -0
- package/build/manager/entrypoints.d.ts.map +1 -0
- package/build/manager/entrypoints.js +17 -0
- package/build/manager/entrypoints.js.map +1 -0
- package/build/manager/errors.d.ts.map +1 -1
- package/build/manager/errors.js.map +1 -1
- package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
- package/build/manager/plugins/basic-ed25519.js +5 -0
- package/build/manager/plugins/basic-ed25519.js.map +1 -1
- package/build/manager/plugins/exports.d.ts +3 -0
- package/build/manager/plugins/exports.d.ts.map +1 -1
- package/build/manager/plugins/exports.js +2 -0
- package/build/manager/plugins/exports.js.map +1 -1
- package/build/manager/plugins/index.d.ts +2 -5
- package/build/manager/plugins/index.d.ts.map +1 -1
- package/build/manager/plugins/index.js +9 -1
- package/build/manager/plugins/index.js.map +1 -1
- package/build/manager/plugins/methods.d.ts +6 -0
- package/build/manager/plugins/methods.d.ts.map +1 -0
- package/build/manager/plugins/methods.js +65 -0
- package/build/manager/plugins/methods.js.map +1 -0
- package/build/manager/plugins/re-captcha.d.ts.map +1 -1
- package/build/manager/plugins/re-captcha.js +5 -0
- package/build/manager/plugins/re-captcha.js.map +1 -1
- package/build/manager/plugins/registry/types.d.ts +10 -0
- package/build/manager/plugins/registry/types.d.ts.map +1 -0
- package/build/manager/plugins/registry/types.js +2 -0
- package/build/manager/plugins/registry/types.js.map +1 -0
- package/build/manager/plugins/registry.d.ts +20 -0
- package/build/manager/plugins/registry.d.ts.map +1 -0
- package/build/manager/plugins/registry.js +23 -0
- package/build/manager/plugins/registry.js.map +1 -0
- package/build/manager/plugins/tunnel/wallet.d.ts.map +1 -1
- package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
- package/build/manager/plugins/tunnel-consumer.js +10 -3
- package/build/manager/plugins/tunnel-consumer.js.map +1 -1
- package/build/manager/plugins/types.d.ts +53 -0
- package/build/manager/plugins/types.d.ts.map +1 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +1 -1
- package/build/service.js.map +1 -1
- package/package.json +33 -30
- package/src/components/dispatcher/component.tsx +37 -14
- package/src/components/dispatcher/types.local.ts +7 -0
- package/src/entrypoints.ts +10 -0
- package/src/helper.ts +31 -5
- package/src/index.ts +1 -1
- package/src/login/adopt/types.ts +25 -0
- package/src/login/adopt.ts +21 -14
- package/src/login/consts.local.ts +34 -0
- package/src/login/consts.ts +105 -5
- package/src/login/credit/types.ts +6 -0
- package/src/login/credit.ts +69 -0
- package/src/login/env/types.ts +35 -0
- package/src/login/env.ts +60 -72
- package/src/login/flow.ts +44 -0
- package/src/login/hook.ts +58 -22
- package/src/login/i18n/be.json +41 -0
- package/src/login/i18n/de.json +41 -0
- package/src/login/i18n/en.json +41 -0
- package/src/login/i18n/es.json +41 -0
- package/src/login/i18n/fr.json +41 -0
- package/src/login/i18n/pl.json +41 -0
- package/src/login/i18n/ru.json +41 -0
- package/src/login/i18n/uk.json +41 -0
- package/src/login/i18n.ts +29 -0
- package/src/login/index.ts +21 -0
- package/src/login/land/types.ts +54 -0
- package/src/login/land.ts +149 -0
- package/src/login/methods/types.ts +32 -0
- package/src/login/methods.ts +109 -0
- package/src/login/resume/types.ts +24 -0
- package/src/login/resume.ts +45 -0
- package/src/login/screen.tsx +140 -0
- package/src/login/service.ts +181 -18
- package/src/login/start/types.ts +30 -0
- package/src/login/start.ts +44 -0
- package/src/login/surrogate/types.ts +9 -0
- package/src/login/surrogate.ts +37 -0
- package/src/login/terms/types.ts +126 -0
- package/src/login/terms-config.ts +55 -0
- package/src/login/terms.ts +315 -0
- package/src/login/types.ts +233 -17
- package/src/manager/README.md +37 -80
- package/src/manager/components/authentication/control.ts +3 -3
- package/src/manager/components/authentication/types.ts +2 -2
- package/src/manager/components/tunnel-consumer.tsx +2 -2
- package/src/manager/entrypoints.ts +18 -0
- package/src/manager/plugins/basic-ed25519.tsx +7 -0
- package/src/manager/plugins/exports.ts +3 -0
- package/src/manager/plugins/index.ts +11 -4
- package/src/manager/plugins/methods.ts +74 -0
- package/src/manager/plugins/re-captcha.tsx +7 -0
- package/src/manager/plugins/registry/types.ts +10 -0
- package/src/manager/plugins/registry.ts +33 -0
- package/src/manager/plugins/tunnel-consumer.tsx +12 -3
- package/src/manager/plugins/types.ts +54 -0
- package/src/service.ts +1 -1
- package/tests/context.ts +1 -2
- package/tests/i18n.spec.ts +68 -0
- package/tests/land.spec.ts +292 -0
- package/tests/login.spec.ts +332 -0
- package/tests/target.spec.ts +210 -0
- package/tests/terms-deferred.spec.ts +65 -0
- package/tests/terms.spec.ts +314 -0
- package/build/.gitkeep +0 -0
- package/src/manager/modules.ts +0 -18
- package/src/modules.ts +0 -13
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makeTestContext } from './context.js'
|
|
3
|
+
import { makeFixtureKeyPair } from '@owlmeans/test-auth'
|
|
4
|
+
import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
|
|
5
|
+
import { AuthroizationType, AuthRole, DISPATCHER } from '@owlmeans/auth'
|
|
6
|
+
import type { Auth } from '@owlmeans/auth'
|
|
7
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
8
|
+
import { createStaticResource } from '@owlmeans/static-resource'
|
|
9
|
+
import type { Resource } from '@owlmeans/resource'
|
|
10
|
+
import { FLOW_STATE, RESUME_FLOW } from '@owlmeans/client-flow'
|
|
11
|
+
import type { SuspendedLandingRecord } from '@owlmeans/client-flow'
|
|
12
|
+
import type { AuthService } from '@owlmeans/auth-common'
|
|
13
|
+
import { DEFAULT_ALIAS as AUTH_ALIAS } from '../src/consts.js'
|
|
14
|
+
import { appendLogin } from '../src/login/service.js'
|
|
15
|
+
import { loginLandingOf } from '../src/login/land.js'
|
|
16
|
+
import { loginStartOf } from '../src/login/start.js'
|
|
17
|
+
import { LoginOutcome } from '../src/login/consts.js'
|
|
18
|
+
import type { LoginPlugin, LoginService } from '../src/login/types.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `useLogin(target)` — sign in FIRST, then land on the target.
|
|
22
|
+
*
|
|
23
|
+
* The hook is a thin React shell over `startLogin` and the login facade, so both halves are
|
|
24
|
+
* exercised here without a DOM: the facade parks the target in the record `resumeSuspendedFlow`
|
|
25
|
+
* reads, the continuation waits for that write and goes to the dispatcher, and the dispatcher's own
|
|
26
|
+
* landing (`landAfterLogin`) ends on the target once a session exists.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
let store = 0
|
|
30
|
+
|
|
31
|
+
/** A validly-signed bearer, exactly as `server-auth` would issue one. */
|
|
32
|
+
const makeBearer = async (): Promise<string> => {
|
|
33
|
+
const appKP = makeFixtureKeyPair('client-auth-target-test-key')
|
|
34
|
+
const auth: Auth = {
|
|
35
|
+
token: 'nonce-target', userId: 'target-user', scopes: ['*'], role: AuthRole.User,
|
|
36
|
+
type: AuthroizationType.Ed25519BasicToken, source: 'test-service', isUser: true,
|
|
37
|
+
createdAt: new Date(),
|
|
38
|
+
}
|
|
39
|
+
const signed = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
|
|
40
|
+
.send(auth, null).sign(appKP, EnvelopeKind.Token)
|
|
41
|
+
|
|
42
|
+
return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${signed}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const bootstrap = async (opts?: {
|
|
46
|
+
authenticated?: boolean
|
|
47
|
+
}): Promise<[BasicContext<BasicConfig>, LoginService, Resource<SuspendedLandingRecord>]> => {
|
|
48
|
+
const context = makeTestContext()
|
|
49
|
+
context.registerResource(
|
|
50
|
+
createStaticResource<SuspendedLandingRecord>(FLOW_STATE, `client-auth-target-tests-${store++}`)
|
|
51
|
+
)
|
|
52
|
+
context.configure()
|
|
53
|
+
await context.init()
|
|
54
|
+
if (opts?.authenticated === true) {
|
|
55
|
+
await context.service<AuthService>(AUTH_ALIAS).update(await makeBearer())
|
|
56
|
+
}
|
|
57
|
+
const login = appendLogin(context).login()
|
|
58
|
+
|
|
59
|
+
return [context, login, context.resource<Resource<SuspendedLandingRecord>>(FLOW_STATE)]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An ordinary tab: the redirect plugin's `begin` runs the caller's continuation and is done. */
|
|
63
|
+
const redirectLike = (): LoginPlugin => ({
|
|
64
|
+
alias: 'redirect-like', match: () => true,
|
|
65
|
+
begin: async (_ctx, request) => {
|
|
66
|
+
await request.navigate?.()
|
|
67
|
+
|
|
68
|
+
return LoginOutcome.Handled
|
|
69
|
+
},
|
|
70
|
+
authorize: async () => LoginOutcome.Redirected,
|
|
71
|
+
complete: async () => LoginOutcome.Passed,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** Grant a fake alias existence without building a real entrypoint/route graph. */
|
|
75
|
+
const allowEntrypoints = (context: BasicContext<BasicConfig>, ...aliases: string[]): void => {
|
|
76
|
+
(context as unknown as { hasEntrypoint: (alias: string) => boolean }).hasEntrypoint =
|
|
77
|
+
alias => aliases.includes(alias)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('startLogin — where a "Log in" control goes', () => {
|
|
81
|
+
test('signed out with a target: the continuation is the dispatcher, never the target', async () => {
|
|
82
|
+
const [context, login] = await bootstrap()
|
|
83
|
+
login.registerPlugin(redirectLike())
|
|
84
|
+
const went: string[] = []
|
|
85
|
+
|
|
86
|
+
expect(await loginStartOf(context).startLogin({
|
|
87
|
+
url: '/dispatcher', target: 'guarded-screen', go: alias => { went.push(alias) },
|
|
88
|
+
})).toBe(LoginOutcome.Handled)
|
|
89
|
+
expect(went).toEqual([DISPATCHER])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('signed in with a target: straight to the target, no sign-in begun', async () => {
|
|
93
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
94
|
+
let begun = false
|
|
95
|
+
login.registerPlugin({
|
|
96
|
+
...redirectLike(), begin: async () => { begun = true; return LoginOutcome.Handled },
|
|
97
|
+
})
|
|
98
|
+
const went: string[] = []
|
|
99
|
+
|
|
100
|
+
expect(await loginStartOf(context).startLogin({
|
|
101
|
+
url: '/dispatcher', target: 'guarded-screen', go: alias => { went.push(alias) },
|
|
102
|
+
})).toBe(LoginOutcome.Handled)
|
|
103
|
+
expect(went).toEqual(['guarded-screen'])
|
|
104
|
+
expect(begun).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('no target: a sign-in to the dispatcher whether or not a session exists, as always', async () => {
|
|
108
|
+
const [context, login, resource] = await bootstrap({ authenticated: true })
|
|
109
|
+
login.registerPlugin(redirectLike())
|
|
110
|
+
const went: string[] = []
|
|
111
|
+
|
|
112
|
+
await loginStartOf(context).startLogin({ url: '/dispatcher', go: alias => { went.push(alias) } })
|
|
113
|
+
expect(went).toEqual([DISPATCHER])
|
|
114
|
+
expect(await resource.load(RESUME_FLOW)).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('the plugin is reached synchronously — a window can still open inside the gesture', async () => {
|
|
118
|
+
const [context, login] = await bootstrap()
|
|
119
|
+
let reached = false
|
|
120
|
+
login.registerPlugin({
|
|
121
|
+
...redirectLike(),
|
|
122
|
+
begin: () => { reached = true; return Promise.resolve(LoginOutcome.Handled) },
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const pending = loginStartOf(context).startLogin({
|
|
126
|
+
url: '/dispatcher', target: 'guarded-screen', go: () => undefined,
|
|
127
|
+
})
|
|
128
|
+
expect(reached).toBe(true)
|
|
129
|
+
await pending
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('the login facade — LoginRequest.target', () => {
|
|
134
|
+
test('the target is parked BEFORE the continuation runs', async () => {
|
|
135
|
+
const [, login, resource] = await bootstrap()
|
|
136
|
+
login.registerPlugin(redirectLike())
|
|
137
|
+
let seen: SuspendedLandingRecord | null = null
|
|
138
|
+
|
|
139
|
+
await login.begin({
|
|
140
|
+
url: '/dispatcher', target: 'guarded-screen',
|
|
141
|
+
navigate: async () => { seen = await resource.load(RESUME_FLOW) },
|
|
142
|
+
})
|
|
143
|
+
expect(seen).not.toBeNull()
|
|
144
|
+
expect(seen!.entrypoint).toBe('guarded-screen')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a refusing precondition parks nothing', async () => {
|
|
148
|
+
const [, login, resource] = await bootstrap()
|
|
149
|
+
login.registerPlugin(redirectLike())
|
|
150
|
+
login.registerPrecondition({ alias: 'refuse', check: () => false })
|
|
151
|
+
|
|
152
|
+
expect(await login.begin({ url: '/dispatcher', target: 'guarded-screen' }))
|
|
153
|
+
.toBe(LoginOutcome.Gesture)
|
|
154
|
+
expect(await resource.load(RESUME_FLOW)).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('an attempt that signs nobody in discards the parked target', async () => {
|
|
158
|
+
for (const outcome of [LoginOutcome.Blocked, LoginOutcome.Failed]) {
|
|
159
|
+
const [, login, resource] = await bootstrap()
|
|
160
|
+
login.registerPlugin({ ...redirectLike(), begin: async () => outcome })
|
|
161
|
+
|
|
162
|
+
expect(await login.begin({ url: '/dispatcher', target: 'guarded-screen' })).toBe(outcome)
|
|
163
|
+
// The discard chains on the write; let both settle.
|
|
164
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
165
|
+
expect(await resource.load(RESUME_FLOW)).toBeNull()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('without a target the request reaches the plugin untouched', async () => {
|
|
170
|
+
const [, login, resource] = await bootstrap()
|
|
171
|
+
const navigate = (): void => undefined
|
|
172
|
+
let received: unknown = null
|
|
173
|
+
login.registerPlugin({
|
|
174
|
+
...redirectLike(),
|
|
175
|
+
begin: async (_ctx, request) => { received = request.navigate; return LoginOutcome.Handled },
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await login.begin({ url: '/dispatcher', navigate })
|
|
179
|
+
expect(received).toBe(navigate)
|
|
180
|
+
expect(await resource.load(RESUME_FLOW)).toBeNull()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('the landing after a targeted sign-in', () => {
|
|
185
|
+
test('the dispatcher lands on the target once signed in', async () => {
|
|
186
|
+
const [context, login] = await bootstrap()
|
|
187
|
+
login.registerPlugin(redirectLike())
|
|
188
|
+
await loginStartOf(context).startLogin({ url: '/dispatcher', target: 'guarded-screen', go: () => undefined })
|
|
189
|
+
|
|
190
|
+
await context.service<AuthService>(AUTH_ALIAS).update(await makeBearer())
|
|
191
|
+
expect(await loginLandingOf(context).landAfterLogin()).toEqual({ alias: 'guarded-screen', query: {} })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('a pending step runs first, and the target is where the step continues to', async () => {
|
|
195
|
+
const [context, login] = await bootstrap()
|
|
196
|
+
allowEntrypoints(context, 'consent-screen')
|
|
197
|
+
login.registerPlugin(redirectLike())
|
|
198
|
+
let consented = false
|
|
199
|
+
login.registerStep({
|
|
200
|
+
alias: 'consent', entrypoint: 'consent-screen', pending: async () => !consented,
|
|
201
|
+
})
|
|
202
|
+
await loginStartOf(context).startLogin({ url: '/dispatcher', target: 'guarded-screen', go: () => undefined })
|
|
203
|
+
|
|
204
|
+
await context.service<AuthService>(AUTH_ALIAS).update(await makeBearer())
|
|
205
|
+
expect(await loginLandingOf(context).landAfterLogin()).toEqual({ alias: 'consent-screen', step: 'consent' })
|
|
206
|
+
consented = true
|
|
207
|
+
expect(await loginLandingOf(context).continueLogin({ after: 'consent' }))
|
|
208
|
+
.toEqual({ alias: 'guarded-screen', query: {} })
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { BasicContext, BasicConfig } from '@owlmeans/context'
|
|
3
|
+
import { makeTestContext } from './context.js'
|
|
4
|
+
import { appendLogin } from '../src/login/service.js'
|
|
5
|
+
import { loginTermsHelper } from '../src/login/terms.js'
|
|
6
|
+
import type { LoginStep } from '../src/login/types.js'
|
|
7
|
+
|
|
8
|
+
/** Grant a fake alias existence without building a real entrypoint/route graph. */
|
|
9
|
+
const allowEntrypoints = (context: BasicContext<BasicConfig>, ...aliases: string[]): void => {
|
|
10
|
+
(context as unknown as { hasEntrypoint: (alias: string) => boolean }).hasEntrypoint =
|
|
11
|
+
alias => aliases.includes(alias)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const step = (over: Partial<LoginStep> & { alias: string, entrypoint: string }): LoginStep => ({
|
|
15
|
+
pending: async () => false, ...over,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('termsDeferred', () => {
|
|
19
|
+
test('false with no login service registered at all', () => {
|
|
20
|
+
const context = makeTestContext()
|
|
21
|
+
|
|
22
|
+
expect(loginTermsHelper.termsDeferred(context)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('false with a login service but no confirming step', () => {
|
|
26
|
+
const context = makeTestContext()
|
|
27
|
+
const contextual = appendLogin(context)
|
|
28
|
+
allowEntrypoints(context, 'consent-screen')
|
|
29
|
+
contextual.login().registerStep(step({ alias: 'other', entrypoint: 'consent-screen' }))
|
|
30
|
+
|
|
31
|
+
expect(loginTermsHelper.termsDeferred(context)).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('false when the confirming step is registered but its screen is not bound', () => {
|
|
35
|
+
const context = makeTestContext()
|
|
36
|
+
const contextual = appendLogin(context)
|
|
37
|
+
// hasEntrypoint left at its default (nothing bound) — an app that registered the step but
|
|
38
|
+
// never bound its screen keeps the sign-in checkbox, fail-closed.
|
|
39
|
+
contextual.login().registerStep(step({
|
|
40
|
+
alias: 'consent', entrypoint: 'consent-screen', confirmsTerms: true,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
expect(loginTermsHelper.termsDeferred(context)).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('true once a confirming step is both registered AND bound', () => {
|
|
47
|
+
const context = makeTestContext()
|
|
48
|
+
const contextual = appendLogin(context)
|
|
49
|
+
allowEntrypoints(context, 'consent-screen')
|
|
50
|
+
contextual.login().registerStep(step({
|
|
51
|
+
alias: 'consent', entrypoint: 'consent-screen', confirmsTerms: true,
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
expect(loginTermsHelper.termsDeferred(context)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a non-confirming step bound elsewhere does not defer', () => {
|
|
58
|
+
const context = makeTestContext()
|
|
59
|
+
const contextual = appendLogin(context)
|
|
60
|
+
allowEntrypoints(context, 'other-screen')
|
|
61
|
+
contextual.login().registerStep(step({ alias: 'other', entrypoint: 'other-screen' }))
|
|
62
|
+
|
|
63
|
+
expect(loginTermsHelper.termsDeferred(context)).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { OWLMEANS_COOKIES_URL, OWLMEANS_PRIVACY_URL, OWLMEANS_TERMS_URL, type LoginTermsConfig } from '@owlmeans/config'
|
|
3
|
+
import { loginTermsHelper } from '../src/login/terms.js'
|
|
4
|
+
import type { ResolvedTermsDocument } from '../src/login/terms/types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The pre-existing digest, copied rather than imported: `resolveTerms`'s own `digest` is private,
|
|
8
|
+
* and re-deriving it here is the whole point — this pins what the ALGORITHM produced before this
|
|
9
|
+
* file grew billing/product/custom documents, so a change to either one is caught independently.
|
|
10
|
+
*/
|
|
11
|
+
const legacyDigest = (parts: string[]): string => {
|
|
12
|
+
let hash = 0
|
|
13
|
+
for (const char of parts.join(' ')) {
|
|
14
|
+
hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (hash >>> 0).toString(36)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LABEL: Record<string, string> = {
|
|
21
|
+
terms: 'Terms & Conditions', privacy: 'Privacy Policy', cookies: 'Cookie Policy',
|
|
22
|
+
billing: 'Billing Terms', product: '{{product}} Product Terms',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A minimal stand-in for a renderer's own `resolveLabel` — no i18n context, just the fallback. */
|
|
26
|
+
const label = (doc: ResolvedTermsDocument): string => {
|
|
27
|
+
let text = doc.label ?? DEFAULT_LABEL[doc.key] ?? doc.i18nKey ?? doc.key
|
|
28
|
+
if (doc.params != null) {
|
|
29
|
+
for (const [key, value] of Object.entries(doc.params)) {
|
|
30
|
+
text = text.split(`{{${key}}}`).join(value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return text
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveTerms — the digest', () => {
|
|
38
|
+
test('is byte-identical to the pre-existing algorithm when nothing new is configured', () => {
|
|
39
|
+
const cfg: LoginTermsConfig = {
|
|
40
|
+
required: true, terms: 'https://a.test/terms', privacy: 'https://a.test/privacy',
|
|
41
|
+
}
|
|
42
|
+
const resolved = loginTermsHelper.resolveTerms(cfg)
|
|
43
|
+
|
|
44
|
+
expect(resolved?.version).toBe(
|
|
45
|
+
legacyDigest(['https://a.test/terms', 'https://a.test/privacy', OWLMEANS_COOKIES_URL])
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a defaulted config (no terms/privacy/cookies at all) matches the same algorithm', () => {
|
|
50
|
+
const resolved = loginTermsHelper.resolveTerms({})
|
|
51
|
+
|
|
52
|
+
expect(resolved?.version).toBe(
|
|
53
|
+
legacyDigest([OWLMEANS_TERMS_URL, OWLMEANS_PRIVACY_URL, OWLMEANS_COOKIES_URL])
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('an explicit version override is used verbatim, as before', () => {
|
|
58
|
+
expect(loginTermsHelper.resolveTerms({ version: 'pinned-v3' })?.version).toBe('pinned-v3')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('adding a billing document changes the digest', () => {
|
|
62
|
+
const base = loginTermsHelper.resolveTerms({})?.version
|
|
63
|
+
const withBilling = loginTermsHelper.resolveTerms({ billing: 'https://a.test/billing' })?.version
|
|
64
|
+
|
|
65
|
+
expect(withBilling).not.toBe(base)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('changing only a revision date changes the digest', () => {
|
|
69
|
+
const unrevised = loginTermsHelper.resolveTerms({})?.version
|
|
70
|
+
const revised = loginTermsHelper.resolveTerms({ revisions: { terms: '2026-01-01' } })?.version
|
|
71
|
+
|
|
72
|
+
expect(revised).not.toBe(unrevised)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('resolveTerms — documents and notices', () => {
|
|
77
|
+
test('billing/product/documents/revisions all populate `documents`, in order', () => {
|
|
78
|
+
// Module-augmentation proof: this whole literal type-checks against `LoginTermsConfig` only
|
|
79
|
+
// because `@owlmeans/client-auth/login`'s augmentation merged onto it — a file that imports
|
|
80
|
+
// NOTHING from this package would fail to compile the `billing`/`product`/`documents`/
|
|
81
|
+
// `revisions`/`showRevision` fields below.
|
|
82
|
+
const cfg: LoginTermsConfig = {
|
|
83
|
+
terms: 'https://a.test/terms',
|
|
84
|
+
billing: { href: 'https://a.test/billing', revisedAt: '2026-02-01' },
|
|
85
|
+
product: { name: 'Acme', href: 'https://a.test/product' },
|
|
86
|
+
documents: [
|
|
87
|
+
{ key: 'custom-a', href: 'https://a.test/custom-a', label: 'Custom A' },
|
|
88
|
+
{ key: 'custom-b', href: 'https://a.test/custom-b', i18nKey: 'login.terms.custom-b' },
|
|
89
|
+
],
|
|
90
|
+
revisions: { terms: '2026-01-15' },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const resolved = loginTermsHelper.resolveTerms(cfg)
|
|
94
|
+
|
|
95
|
+
expect(resolved?.documents.map(doc => doc.key))
|
|
96
|
+
.toEqual(['terms', 'billing', 'product', 'custom-a', 'custom-b'])
|
|
97
|
+
expect(resolved?.documents[0].revisedAt).toBe('2026-01-15')
|
|
98
|
+
expect(resolved?.documents[1].href).toBe('https://a.test/billing')
|
|
99
|
+
expect(resolved?.documents[1].revisedAt).toBe('2026-02-01')
|
|
100
|
+
expect(resolved?.documents[2].href).toBe('https://a.test/product')
|
|
101
|
+
expect(resolved?.documents[2].params).toEqual({ product: 'Acme' })
|
|
102
|
+
expect(resolved?.documents[3]).toMatchObject({ key: 'custom-a', href: 'https://a.test/custom-a', label: 'Custom A' })
|
|
103
|
+
expect(resolved?.documents[4]).toMatchObject({ key: 'custom-b', href: 'https://a.test/custom-b', i18nKey: 'login.terms.custom-b' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a bare billing string is treated as `{ href }`', () => {
|
|
107
|
+
const resolved = loginTermsHelper.resolveTerms({ billing: 'https://a.test/billing' })
|
|
108
|
+
|
|
109
|
+
expect(resolved?.documents.find(doc => doc.key === 'billing')?.href).toBe('https://a.test/billing')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('`false` turns billing/product off — the default', () => {
|
|
113
|
+
const resolved = loginTermsHelper.resolveTerms({ billing: false, product: false })
|
|
114
|
+
|
|
115
|
+
expect(resolved?.documents.map(doc => doc.key)).toEqual(['terms'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('the cookies notice is included when explicitly configured', () => {
|
|
119
|
+
const resolved = loginTermsHelper.resolveTerms({
|
|
120
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy', 'cookies'])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('the cookies notice is included when terms/privacy/cookies are ALL still the defaults', () => {
|
|
127
|
+
const resolved = loginTermsHelper.resolveTerms({})
|
|
128
|
+
|
|
129
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy', 'cookies'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('the cookies notice is DROPPED when terms/privacy were customised but cookies was not', () => {
|
|
133
|
+
const resolved = loginTermsHelper.resolveTerms({ terms: 'https://a.test/terms', privacy: 'https://a.test/privacy' })
|
|
134
|
+
|
|
135
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('`revisedAt` is set only when `showRevision` is true', () => {
|
|
139
|
+
const cfg: LoginTermsConfig = { revisions: { terms: '2026-03-01' } }
|
|
140
|
+
|
|
141
|
+
expect(loginTermsHelper.resolveTerms(cfg)?.revisedAt).toBeUndefined()
|
|
142
|
+
expect(loginTermsHelper.resolveTerms({ ...cfg, showRevision: true })?.revisedAt).toBe('2026-03-01')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('`revisedAt` is the LATEST revision among documents', () => {
|
|
146
|
+
const cfg: LoginTermsConfig = {
|
|
147
|
+
showRevision: true,
|
|
148
|
+
billing: { href: 'https://a.test/billing', revisedAt: '2026-01-01' },
|
|
149
|
+
revisions: { terms: '2026-06-01' },
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
expect(loginTermsHelper.resolveTerms(cfg)?.revisedAt).toBe('2026-06-01')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('termsSentence', () => {
|
|
157
|
+
test('interpolates {{documents}} as a formatted, linked list', () => {
|
|
158
|
+
const resolved = loginTermsHelper.resolveTerms({
|
|
159
|
+
terms: 'https://a.test/terms', billing: 'https://a.test/billing',
|
|
160
|
+
})!
|
|
161
|
+
|
|
162
|
+
const parts = loginTermsHelper.termsSentence('I agree to the {{documents}}.', resolved, 'en', label)
|
|
163
|
+
const linked = parts.filter(part => part.href != null)
|
|
164
|
+
|
|
165
|
+
expect(linked.map(part => part.documentKey)).toEqual(['terms', 'billing'])
|
|
166
|
+
expect(parts[0]).toEqual({ text: 'I agree to the ' })
|
|
167
|
+
expect(parts.at(-1)).toEqual({ text: '.' })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('interpolates {{notices}} the same way, from `resolved.notices`', () => {
|
|
171
|
+
const resolved = loginTermsHelper.resolveTerms({
|
|
172
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
173
|
+
})!
|
|
174
|
+
|
|
175
|
+
const parts = loginTermsHelper.termsSentence('Data: {{notices}}.', resolved, 'en', label)
|
|
176
|
+
const linked = parts.filter(part => part.href != null)
|
|
177
|
+
|
|
178
|
+
expect(linked.map(part => part.documentKey)).toEqual(['privacy', 'cookies'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('supports the legacy {{terms}}/{{privacy}}/{{cookies}} placeholders', () => {
|
|
182
|
+
const resolved = loginTermsHelper.resolveTerms({
|
|
183
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
184
|
+
})!
|
|
185
|
+
|
|
186
|
+
const parts = loginTermsHelper.termsSentence('{{terms}} and {{privacy}} and {{cookies}}', resolved, 'en', label)
|
|
187
|
+
const linked = parts.filter(part => part.href != null)
|
|
188
|
+
|
|
189
|
+
expect(linked.map(part => part.documentKey)).toEqual(['terms', 'privacy', 'cookies'])
|
|
190
|
+
expect(linked.map(part => part.href))
|
|
191
|
+
.toEqual(['https://a.test/terms', 'https://a.test/privacy', 'https://a.test/cookies'])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('a single document renders with no list separator', () => {
|
|
195
|
+
const resolved = loginTermsHelper.resolveTerms({ terms: 'https://a.test/terms' })!
|
|
196
|
+
|
|
197
|
+
const parts = loginTermsHelper.termsSentence('{{documents}}', resolved, 'en', label)
|
|
198
|
+
|
|
199
|
+
expect(parts).toEqual([{ text: 'Terms & Conditions', href: 'https://a.test/terms', documentKey: 'terms' }])
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('termsLabelResolver', () => {
|
|
204
|
+
test('resolves a document label the same way the two renderers used to, by hand', () => {
|
|
205
|
+
const translate = (key: string, defaultValue: string) => key === 'login.terms.custom' ? 'Custom!' : defaultValue
|
|
206
|
+
const resolve = loginTermsHelper.termsLabelResolver(translate, 'en')
|
|
207
|
+
|
|
208
|
+
expect(resolve({ key: 'terms', href: 'https://a.test/terms', i18nKey: 'login.terms.terms' }))
|
|
209
|
+
.toBe('Terms & Conditions')
|
|
210
|
+
expect(resolve({ key: 'x', href: 'https://a.test/x', i18nKey: 'login.terms.custom' })).toBe('Custom!')
|
|
211
|
+
expect(resolve({ key: 'x', href: 'https://a.test/x', label: 'Literal' })).toBe('Literal')
|
|
212
|
+
expect(resolve({ key: 'x', href: 'https://a.test/x', labelMap: { en: 'English', fr: 'Français' } }))
|
|
213
|
+
.toBe('English')
|
|
214
|
+
expect(resolve({
|
|
215
|
+
key: 'product', href: 'https://a.test/p', i18nKey: 'login.terms.product', params: { product: 'Acme' },
|
|
216
|
+
})).toBe('{{product}} Product Terms'.replace('{{product}}', 'Acme'))
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('termsAcceptanceOf', () => {
|
|
221
|
+
test('keeps only key/href/revisedAt — never i18nKey, params or label', () => {
|
|
222
|
+
// A viable-shaped config: billing, a product document carrying `params`, and revisions —
|
|
223
|
+
// exactly what `termsRecorder`/a Terms-mode consent screen sends the server.
|
|
224
|
+
const resolved = loginTermsHelper.resolveTerms({
|
|
225
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
226
|
+
billing: { href: 'https://a.test/billing', revisedAt: '2026-02-01' },
|
|
227
|
+
product: { name: 'Acme', href: 'https://a.test/product' },
|
|
228
|
+
revisions: { terms: '2026-01-15', privacy: '2026-01-15' },
|
|
229
|
+
})!
|
|
230
|
+
|
|
231
|
+
const acceptance = loginTermsHelper.termsAcceptanceOf(resolved, 'en')
|
|
232
|
+
|
|
233
|
+
expect(acceptance.version).toBe(resolved.version)
|
|
234
|
+
expect(acceptance.locale).toBe('en')
|
|
235
|
+
expect(acceptance.documents).toEqual([
|
|
236
|
+
{ key: 'terms', href: 'https://a.test/terms', revisedAt: '2026-01-15' },
|
|
237
|
+
{ key: 'billing', href: 'https://a.test/billing', revisedAt: '2026-02-01' },
|
|
238
|
+
{ key: 'product', href: 'https://a.test/product', revisedAt: undefined },
|
|
239
|
+
])
|
|
240
|
+
expect(acceptance.notices).toEqual([
|
|
241
|
+
{ key: 'privacy', href: 'https://a.test/privacy', revisedAt: '2026-01-15' },
|
|
242
|
+
{ key: 'cookies', href: 'https://a.test/cookies', revisedAt: undefined },
|
|
243
|
+
])
|
|
244
|
+
// Every key is exactly {key, href, revisedAt} — no i18nKey, params or label leaked through.
|
|
245
|
+
for (const doc of [...acceptance.documents, ...acceptance.notices]) {
|
|
246
|
+
expect(Object.keys(doc).sort()).toEqual(['href', 'key', 'revisedAt'])
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('omits `locale` entirely when none is given', () => {
|
|
251
|
+
const resolved = loginTermsHelper.resolveTerms({ terms: 'https://a.test/terms' })!
|
|
252
|
+
|
|
253
|
+
expect(loginTermsHelper.termsAcceptanceOf(resolved)).not.toHaveProperty('locale')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('explicit localized document destinations', () => {
|
|
258
|
+
const cfg: LoginTermsConfig = {
|
|
259
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
260
|
+
billing: 'https://a.test/billing',
|
|
261
|
+
revisions: { terms: '2026-05-30', privacy: '2026-05-30', billing: '2026-10-04' },
|
|
262
|
+
}
|
|
263
|
+
const localizedHrefs = {
|
|
264
|
+
terms: { pl: 'https://a.test/pl/warunki', fr: 'https://a.test/fr/conditions' },
|
|
265
|
+
billing: { pl: 'https://a.test/pl/abonament', fr: 'https://a.test/fr/abonnement' },
|
|
266
|
+
privacy: { pl: 'https://a.test/pl/prywatnosc', fr: 'https://a.test/fr/confidentialite' },
|
|
267
|
+
cookies: { pl: 'https://a.test/pl/cookies', fr: 'https://a.test/fr/cookies' },
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test('translated paths leave the canonical digest and document revisions unchanged', () => {
|
|
271
|
+
const canonical = loginTermsHelper.resolveTerms(cfg)!
|
|
272
|
+
const localized = loginTermsHelper.resolveTerms({ ...cfg, localizedHrefs })!
|
|
273
|
+
|
|
274
|
+
expect(localized.version).toBe(canonical.version)
|
|
275
|
+
for (const locale of ['pl', 'fr']) {
|
|
276
|
+
const acceptance = loginTermsHelper.termsAcceptanceOf(localized, locale)
|
|
277
|
+
expect(acceptance.version).toBe(canonical.version)
|
|
278
|
+
expect(acceptance.documents.map(doc => doc.revisedAt)).toEqual(canonical.documents.map(doc => doc.revisedAt))
|
|
279
|
+
expect(acceptance.notices.map(doc => doc.revisedAt)).toEqual(canonical.notices.map(doc => doc.revisedAt))
|
|
280
|
+
}
|
|
281
|
+
expect(localized.documents.map(doc => doc.href)).toEqual(canonical.documents.map(doc => doc.href))
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('rendered lists, legacy placeholders and recorded evidence select the same PL/FR URLs', () => {
|
|
285
|
+
const resolved = loginTermsHelper.resolveTerms({ ...cfg, localizedHrefs })!
|
|
286
|
+
for (const locale of ['pl', 'fr']) {
|
|
287
|
+
const parts = loginTermsHelper.termsSentence('{{documents}}; {{notices}}', resolved, locale, label)
|
|
288
|
+
const evidence = loginTermsHelper.termsAcceptanceOf(resolved, locale)
|
|
289
|
+
expect(parts.filter(part => part.href != null).map(({ documentKey, href }) => ({ key: documentKey, href })))
|
|
290
|
+
.toEqual([...evidence.documents, ...evidence.notices].map(({ key, href }) => ({ key, href })))
|
|
291
|
+
expect(evidence.documents[0].href).toBe(localizedHrefs.terms[locale as 'pl' | 'fr'])
|
|
292
|
+
expect(evidence.notices[0].href).toBe(localizedHrefs.privacy[locale as 'pl' | 'fr'])
|
|
293
|
+
const legacy = loginTermsHelper.termsSentence('{{terms}} {{privacy}} {{cookies}}', resolved, locale, label)
|
|
294
|
+
expect(legacy.filter(part => part.href != null).map(part => part.href))
|
|
295
|
+
.toEqual([evidence.documents[0].href, ...evidence.notices.map(doc => doc.href)])
|
|
296
|
+
for (const doc of [...evidence.documents, ...evidence.notices]) {
|
|
297
|
+
expect(Object.keys(doc).sort()).toEqual(['href', 'key', 'revisedAt'])
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('unmapped/default locales retain canonical destinations; regional tags use a configured base language', () => {
|
|
303
|
+
const canonical = loginTermsHelper.resolveTerms(cfg)!
|
|
304
|
+
const resolved = loginTermsHelper.resolveTerms({ ...cfg, localizedHrefs })!
|
|
305
|
+
for (const locale of [undefined, 'en', 'it']) {
|
|
306
|
+
expect(loginTermsHelper.termsAcceptanceOf(resolved, locale))
|
|
307
|
+
.toEqual(loginTermsHelper.termsAcceptanceOf(canonical, locale))
|
|
308
|
+
}
|
|
309
|
+
expect(loginTermsHelper.termsHrefOf(resolved.documents[0], 'pl-PL')).toBe(localizedHrefs.terms.pl)
|
|
310
|
+
expect(loginTermsHelper.termsHrefOf(resolved.documents[0], 'fr-FR')).toBe(localizedHrefs.terms.fr)
|
|
311
|
+
// Omitted maps never rewrite a customer application's URLs, even in a non-English UI.
|
|
312
|
+
expect(loginTermsHelper.termsHrefOf(canonical.documents[0], 'pl')).toBe(cfg.terms!)
|
|
313
|
+
})
|
|
314
|
+
})
|
package/build/.gitkeep
DELETED
|
File without changes
|
package/src/manager/modules.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { AUTHEN, AUTHEN_AUTHEN, AUTHEN_INIT, AUTHEN_RELY, CAUTHEN, CAUTHEN_AUTHEN, CAUTHEN_AUTHEN_DEFAULT, CAUTHEN_AUTHEN_TYPED, DISPATCHER } from '@owlmeans/auth'
|
|
3
|
-
import { modules as list } from '@owlmeans/auth-common'
|
|
4
|
-
import { handler } from '@owlmeans/client'
|
|
5
|
-
import { elevate, stab } from '@owlmeans/client-entrypoint'
|
|
6
|
-
import { AuthenticationHOC } from './components/authentication/component.js'
|
|
7
|
-
|
|
8
|
-
elevate(list, AUTHEN)
|
|
9
|
-
elevate(list, AUTHEN_INIT, true)
|
|
10
|
-
elevate(list, AUTHEN_AUTHEN, true)
|
|
11
|
-
elevate(list, AUTHEN_RELY)
|
|
12
|
-
elevate(list, CAUTHEN)
|
|
13
|
-
elevate(list, CAUTHEN_AUTHEN)
|
|
14
|
-
elevate(list, CAUTHEN_AUTHEN_DEFAULT, handler(AuthenticationHOC()))
|
|
15
|
-
elevate(list, CAUTHEN_AUTHEN_TYPED, handler(AuthenticationHOC()))
|
|
16
|
-
elevate(list, DISPATCHER, stab, { force: true })
|
|
17
|
-
|
|
18
|
-
export const modules = list
|
package/src/modules.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { elevate, stab } from '@owlmeans/client-entrypoint'
|
|
3
|
-
import { modules as list } from '@owlmeans/auth-common'
|
|
4
|
-
import { CAUTHEN_FLOW_ENTER, DISPATCHER_AUTHEN } from '@owlmeans/auth'
|
|
5
|
-
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
6
|
-
|
|
7
|
-
elevate(list, DISPATCHER_AUTHEN)
|
|
8
|
-
|
|
9
|
-
export const modules: ClientEntrypoint[] = list as ClientEntrypoint[]
|
|
10
|
-
|
|
11
|
-
export const setupExternalAuthentication = (service: string) => {
|
|
12
|
-
elevate(list, CAUTHEN_FLOW_ENTER, stab, { routeOptions: { overrides: { service } } })
|
|
13
|
-
}
|