@owlmeans/client-auth 0.1.18-rc.44 → 0.1.18-rc.45
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-auth/SKILL.md +10 -7
- package/build/components/dispatcher/component.d.ts.map +1 -1
- package/build/components/dispatcher/component.js +19 -12
- package/build/components/dispatcher/component.js.map +1 -1
- package/build/login/i18n/be.json +6 -2
- package/build/login/i18n/de.json +6 -2
- package/build/login/i18n/en.json +6 -2
- package/build/login/i18n/es.json +6 -2
- package/build/login/i18n/fr.json +6 -2
- package/build/login/i18n/pl.json +6 -2
- package/build/login/i18n/ru.json +6 -2
- package/build/login/i18n/uk.json +6 -2
- package/build/login/index.d.ts +2 -0
- package/build/login/index.d.ts.map +1 -1
- package/build/login/index.js +2 -0
- package/build/login/index.js.map +1 -1
- package/build/login/land.d.ts +44 -0
- package/build/login/land.d.ts.map +1 -0
- package/build/login/land.js +158 -0
- package/build/login/land.js.map +1 -0
- package/build/login/screen.d.ts.map +1 -1
- package/build/login/screen.js +27 -2
- package/build/login/screen.js.map +1 -1
- package/build/login/service.d.ts.map +1 -1
- package/build/login/service.js +20 -0
- package/build/login/service.js.map +1 -1
- package/build/login/terms-config.d.ts +59 -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 +38 -0
- package/build/login/terms.d.ts.map +1 -1
- package/build/login/terms.js +0 -0
- package/build/login/terms.js.map +1 -1
- package/build/login/types.d.ts +77 -0
- package/build/login/types.d.ts.map +1 -1
- package/package.json +22 -22
- package/src/components/dispatcher/component.tsx +25 -12
- package/src/login/i18n/be.json +6 -2
- package/src/login/i18n/de.json +6 -2
- package/src/login/i18n/en.json +6 -2
- package/src/login/i18n/es.json +6 -2
- package/src/login/i18n/fr.json +6 -2
- package/src/login/i18n/pl.json +6 -2
- package/src/login/i18n/ru.json +6 -2
- package/src/login/i18n/uk.json +6 -2
- package/src/login/index.ts +2 -0
- package/src/login/land.ts +186 -0
- package/src/login/screen.tsx +58 -14
- package/src/login/service.ts +26 -2
- package/src/login/terms-config.ts +49 -0
- package/src/login/terms.ts +0 -0
- package/src/login/types.ts +82 -0
- package/src/manager/README.md +1 -1
- package/tests/i18n.spec.ts +68 -0
- package/tests/land.spec.ts +241 -0
- package/tests/terms.spec.ts +202 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import en from '../src/login/i18n/en.json'
|
|
3
|
+
import pl from '../src/login/i18n/pl.json'
|
|
4
|
+
import ru from '../src/login/i18n/ru.json'
|
|
5
|
+
import be from '../src/login/i18n/be.json'
|
|
6
|
+
import uk from '../src/login/i18n/uk.json'
|
|
7
|
+
import es from '../src/login/i18n/es.json'
|
|
8
|
+
import de from '../src/login/i18n/de.json'
|
|
9
|
+
import fr from '../src/login/i18n/fr.json'
|
|
10
|
+
|
|
11
|
+
type Messages = { [key: string]: string | Messages }
|
|
12
|
+
|
|
13
|
+
/** Every leaf as `branch.key` → message, however deep the resource nests. */
|
|
14
|
+
const leaves = (messages: Messages, prefix = ''): Map<string, unknown> => {
|
|
15
|
+
const found = new Map<string, unknown>()
|
|
16
|
+
for (const [key, value] of Object.entries(messages)) {
|
|
17
|
+
const path = prefix === '' ? key : `${prefix}.${key}`
|
|
18
|
+
if (value != null && typeof value === 'object') {
|
|
19
|
+
for (const [leaf, message] of leaves(value, path)) found.set(leaf, message)
|
|
20
|
+
} else {
|
|
21
|
+
found.set(path, value)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return found
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const placeholders = (message: string): string[] =>
|
|
28
|
+
[...message.matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map(match => match[1]).sort()
|
|
29
|
+
|
|
30
|
+
describe('client-auth login localization', () => {
|
|
31
|
+
const translations: Record<string, Messages> = { en, pl, ru, be, uk, es, de, fr }
|
|
32
|
+
const reference = leaves(en)
|
|
33
|
+
|
|
34
|
+
test('ships all eight languages with exact key parity at every depth', () => {
|
|
35
|
+
expect(Object.keys(translations).sort()).toEqual(['be', 'de', 'en', 'es', 'fr', 'pl', 'ru', 'uk'])
|
|
36
|
+
expect(reference.has('login.terms.accept')).toBe(true)
|
|
37
|
+
expect(reference.has('login.terms.notice')).toBe(true)
|
|
38
|
+
expect(reference.has('login.terms.revised')).toBe(true)
|
|
39
|
+
// The removed key never comes back once every renderer has moved off it.
|
|
40
|
+
expect(reference.has('login.terms.agreement')).toBe(false)
|
|
41
|
+
|
|
42
|
+
for (const [lng, messages] of Object.entries(translations)) {
|
|
43
|
+
const found = leaves(messages)
|
|
44
|
+
expect({ lng, keys: [...found.keys()].sort() }).toEqual({ lng, keys: [...reference.keys()].sort() })
|
|
45
|
+
for (const [key, message] of found) {
|
|
46
|
+
expect({ lng, key, filled: typeof message === 'string' && message.trim().length > 0 })
|
|
47
|
+
.toEqual({ lng, key, filled: true })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('keeps the interpolation placeholders of every message', () => {
|
|
53
|
+
for (const [lng, messages] of Object.entries(translations)) {
|
|
54
|
+
for (const [key, message] of leaves(messages)) {
|
|
55
|
+
expect({ lng, key, placeholders: placeholders(String(message)) })
|
|
56
|
+
.toEqual({ lng, key, placeholders: placeholders(String(reference.get(key))) })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('`login.terms.required` no longer names the Privacy Policy specifically', () => {
|
|
62
|
+
for (const [lng, messages] of Object.entries(translations)) {
|
|
63
|
+
const required = leaves(messages).get('login.terms.required')
|
|
64
|
+
expect({ lng, hasDocumentsToken: placeholders(String(required)).includes('documents') })
|
|
65
|
+
.toEqual({ lng, hasDocumentsToken: true })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { afterEach, 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 { HOME } from '@owlmeans/context'
|
|
8
|
+
import type { BasicContext, BasicConfig } from '@owlmeans/context'
|
|
9
|
+
import { createStaticResource } from '@owlmeans/static-resource'
|
|
10
|
+
import type { Resource } from '@owlmeans/resource'
|
|
11
|
+
import { FLOW_STATE, RESUME_FLOW } from '@owlmeans/client-flow'
|
|
12
|
+
import type { SuspendedLandingRecord } from '@owlmeans/client-flow'
|
|
13
|
+
import { DEFAULT_ALIAS as AUTH_ALIAS } from '../src/consts.js'
|
|
14
|
+
import type { AuthService } from '@owlmeans/auth-common'
|
|
15
|
+
import { appendLogin } from '../src/login/service.js'
|
|
16
|
+
import { continueLogin, landAfterLogin, LOGIN_LANDED_STORAGE } from '../src/login/land.js'
|
|
17
|
+
import type { LoginLandingHook, LoginService, LoginStep } from '../src/login/types.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A hand-written `window.localStorage`, not jsdom — `land.ts` reads exactly two keys through
|
|
21
|
+
* `try`/`catch`, and a real DOM would prove a fixture behaves rather than that the module does.
|
|
22
|
+
* Mirrors `web-client/tests/surrogate.spec.ts`'s own `stubWindow` idiom.
|
|
23
|
+
*/
|
|
24
|
+
const stubStorage = (): void => {
|
|
25
|
+
const store = new Map<string, string>()
|
|
26
|
+
;(globalThis as unknown as { window: unknown }).window = {
|
|
27
|
+
localStorage: {
|
|
28
|
+
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
|
29
|
+
setItem: (key: string, value: string) => { store.set(key, value) },
|
|
30
|
+
removeItem: (key: string) => { store.delete(key) },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
delete (globalThis as { window?: unknown }).window
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** A validly-signed bearer, exactly as `server-auth` would issue one. */
|
|
40
|
+
const makeBearer = async (auth: Partial<Auth>): Promise<string> => {
|
|
41
|
+
const appKP = makeFixtureKeyPair('client-auth-land-test-key')
|
|
42
|
+
const fullAuth: Auth = {
|
|
43
|
+
token: 'nonce-123', userId: 'user-1', scopes: ['*'], role: AuthRole.User,
|
|
44
|
+
type: AuthroizationType.Ed25519BasicToken, source: 'test-service', isUser: true,
|
|
45
|
+
createdAt: new Date(), ...auth,
|
|
46
|
+
}
|
|
47
|
+
const signed = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
|
|
48
|
+
.send(fullAuth, null).sign(appKP, EnvelopeKind.Token)
|
|
49
|
+
|
|
50
|
+
return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${signed}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const bootstrap = async (opts?: {
|
|
54
|
+
authenticated?: boolean, withFlowState?: boolean
|
|
55
|
+
}): Promise<[BasicContext<BasicConfig>, LoginService]> => {
|
|
56
|
+
const context = makeTestContext()
|
|
57
|
+
if (opts?.withFlowState) {
|
|
58
|
+
context.registerResource(
|
|
59
|
+
createStaticResource<SuspendedLandingRecord>(FLOW_STATE, 'client-auth-land-tests')
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
context.configure()
|
|
63
|
+
await context.init()
|
|
64
|
+
|
|
65
|
+
if (opts?.authenticated) {
|
|
66
|
+
const authService = context.service<AuthService>(AUTH_ALIAS)
|
|
67
|
+
await authService.update(await makeBearer({ userId: 'land-test-user' }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const contextual = appendLogin(context)
|
|
71
|
+
|
|
72
|
+
return [context, contextual.login()]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const step = (over: Partial<LoginStep> & { alias: string, entrypoint: string }): LoginStep => ({
|
|
76
|
+
pending: async () => false, ...over,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/** Grant a fake alias existence without building a real entrypoint/route graph. */
|
|
80
|
+
const allowEntrypoints = (context: BasicContext<BasicConfig>, ...aliases: string[]): void => {
|
|
81
|
+
(context as unknown as { hasEntrypoint: (alias: string) => boolean }).hasEntrypoint =
|
|
82
|
+
alias => aliases.includes(alias)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('continueLogin — no steps registered', () => {
|
|
86
|
+
test('falls through to a suspended flow, then HOME', async () => {
|
|
87
|
+
const [context] = await bootstrap({ authenticated: true })
|
|
88
|
+
|
|
89
|
+
expect(await continueLogin(context)).toEqual({ alias: HOME })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('continueLogin — steps', () => {
|
|
94
|
+
test('a pending step lands there, and a suspended landing is NOT consumed', async () => {
|
|
95
|
+
stubStorage()
|
|
96
|
+
const [context, login] = await bootstrap({ authenticated: true, withFlowState: true })
|
|
97
|
+
allowEntrypoints(context, 'step-screen')
|
|
98
|
+
|
|
99
|
+
const resource = context.resource<Resource<SuspendedLandingRecord>>(FLOW_STATE)
|
|
100
|
+
await resource.save({
|
|
101
|
+
id: RESUME_FLOW, entrypoint: 'suspended-screen', query: { ref: 'abc' },
|
|
102
|
+
expiresAt: Date.now() + 60_000,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
login.registerStep(step({
|
|
106
|
+
alias: 'consent', entrypoint: 'step-screen', pending: async () => true,
|
|
107
|
+
query: () => ({ from: 'test' }),
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
expect(await continueLogin(context)).toEqual({
|
|
111
|
+
alias: 'step-screen', query: { from: 'test' }, step: 'consent',
|
|
112
|
+
})
|
|
113
|
+
// The suspended landing is still there — a later step, or the eventual fallback, still needs it.
|
|
114
|
+
expect(await resource.load(RESUME_FLOW)).not.toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a step whose `pending` throws is treated as not pending — fail open', async () => {
|
|
118
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
119
|
+
allowEntrypoints(context, 'broken-screen')
|
|
120
|
+
|
|
121
|
+
login.registerStep(step({
|
|
122
|
+
alias: 'broken', entrypoint: 'broken-screen',
|
|
123
|
+
pending: async () => { throw new Error('boom') },
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
expect(await continueLogin(context)).toEqual({ alias: HOME })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a step whose `pending` never resolves is treated as not pending after the timeout', async () => {
|
|
130
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
131
|
+
allowEntrypoints(context, 'hanging-screen')
|
|
132
|
+
|
|
133
|
+
login.registerStep(step({
|
|
134
|
+
alias: 'hanging', entrypoint: 'hanging-screen',
|
|
135
|
+
pending: () => new Promise<boolean>(() => { /* never settles */ }),
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
expect(await continueLogin(context, { stepTimeout: 20 })).toEqual({ alias: HOME })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('a step whose entrypoint is unresolvable is skipped, never throws', async () => {
|
|
142
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
143
|
+
let called = false
|
|
144
|
+
|
|
145
|
+
login.registerStep(step({
|
|
146
|
+
alias: 'unbound', entrypoint: 'nowhere', pending: async () => { called = true; return true },
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
expect(await continueLogin(context)).toEqual({ alias: HOME })
|
|
150
|
+
expect(called).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('`after` skips every step up to and including that alias', async () => {
|
|
154
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
155
|
+
allowEntrypoints(context, 'a-screen', 'b-screen')
|
|
156
|
+
|
|
157
|
+
login.registerStep(step({ alias: 'a', entrypoint: 'a-screen', priority: 20, pending: async () => true }))
|
|
158
|
+
login.registerStep(step({ alias: 'b', entrypoint: 'b-screen', priority: 10, pending: async () => true }))
|
|
159
|
+
|
|
160
|
+
expect(await continueLogin(context)).toEqual({ alias: 'a-screen', query: undefined, step: 'a' })
|
|
161
|
+
expect(await continueLogin(context, { after: 'a' }))
|
|
162
|
+
.toEqual({ alias: 'b-screen', query: undefined, step: 'b' })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('an unauthenticated caller with an explicit alias still gets that alias', async () => {
|
|
166
|
+
const [context] = await bootstrap()
|
|
167
|
+
|
|
168
|
+
expect(await continueLogin(context, {
|
|
169
|
+
fallback: { alias: 'explicit-alias' }, resume: false,
|
|
170
|
+
})).toEqual({ alias: 'explicit-alias' })
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('landAfterLogin — the surrogate short circuit', () => {
|
|
175
|
+
test('returns DISPATCHER immediately; no step or hook ever runs', async () => {
|
|
176
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
177
|
+
login.env = () => ({ hasWindow: true, embedded: true, surrogate: true, hasOpener: true })
|
|
178
|
+
|
|
179
|
+
let stepCalled = false
|
|
180
|
+
let hookCalled = false
|
|
181
|
+
login.registerStep(step({
|
|
182
|
+
alias: 'never', entrypoint: 'never-screen', pending: async () => { stepCalled = true; return true },
|
|
183
|
+
}))
|
|
184
|
+
login.onLanded({ alias: 'never', landed: async () => { hookCalled = true } })
|
|
185
|
+
|
|
186
|
+
expect(await landAfterLogin(context)).toEqual({ alias: DISPATCHER })
|
|
187
|
+
expect(stepCalled).toBe(false)
|
|
188
|
+
expect(hookCalled).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('landAfterLogin — landing hooks', () => {
|
|
193
|
+
const hook = (over: Partial<LoginLandingHook> & { alias: string }): LoginLandingHook => ({
|
|
194
|
+
landed: async () => { }, ...over,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('run exactly once per distinct token, in priority order, each swallowed on error', async () => {
|
|
198
|
+
stubStorage()
|
|
199
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
200
|
+
|
|
201
|
+
const order: string[] = []
|
|
202
|
+
login.onLanded(hook({ alias: 'low', priority: 0, landed: async () => { order.push('low') } }))
|
|
203
|
+
login.onLanded(hook({
|
|
204
|
+
alias: 'high', priority: 10, landed: async () => { order.push('high'); throw new Error('boom') },
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
await landAfterLogin(context)
|
|
208
|
+
expect(order).toEqual(['high', 'low'])
|
|
209
|
+
|
|
210
|
+
// Same token again — neither hook runs a second time.
|
|
211
|
+
await landAfterLogin(context)
|
|
212
|
+
expect(order).toEqual(['high', 'low'])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('a distinct token runs the hooks again', async () => {
|
|
216
|
+
stubStorage()
|
|
217
|
+
const [context, login] = await bootstrap({ authenticated: true })
|
|
218
|
+
|
|
219
|
+
let count = 0
|
|
220
|
+
login.onLanded(hook({ alias: 'counter', landed: async () => { count += 1 } }))
|
|
221
|
+
|
|
222
|
+
await landAfterLogin(context)
|
|
223
|
+
expect(count).toBe(1)
|
|
224
|
+
|
|
225
|
+
const authService = context.service<AuthService>(AUTH_ALIAS)
|
|
226
|
+
await authService.update(await makeBearer({ userId: 'a-different-user' }))
|
|
227
|
+
|
|
228
|
+
await landAfterLogin(context)
|
|
229
|
+
expect(count).toBe(2)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('storage records the raw token, not a digest, under LOGIN_LANDED_STORAGE', async () => {
|
|
233
|
+
stubStorage()
|
|
234
|
+
const [context] = await bootstrap({ authenticated: true })
|
|
235
|
+
const authService = context.service<AuthService>(AUTH_ALIAS)
|
|
236
|
+
|
|
237
|
+
await landAfterLogin(context)
|
|
238
|
+
|
|
239
|
+
expect(window.localStorage.getItem(LOGIN_LANDED_STORAGE)).toBe(authService.token as string)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { OWLMEANS_COOKIES_URL, OWLMEANS_PRIVACY_URL, OWLMEANS_TERMS_URL } from '@owlmeans/config'
|
|
3
|
+
import type { LoginTermsConfig } from '@owlmeans/config'
|
|
4
|
+
import { resolveTerms, termsSentence } from '../src/login/terms.js'
|
|
5
|
+
import type { ResolvedTermsDocument } from '../src/login/terms.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The pre-existing digest, copied rather than imported: `resolveTerms`'s own `digest` is private,
|
|
9
|
+
* and re-deriving it here is the whole point — this pins what the ALGORITHM produced before this
|
|
10
|
+
* file grew billing/product/custom documents, so a change to either one is caught independently.
|
|
11
|
+
*/
|
|
12
|
+
const legacyDigest = (parts: string[]): string => {
|
|
13
|
+
let hash = 0
|
|
14
|
+
for (const char of parts.join(' ')) {
|
|
15
|
+
hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (hash >>> 0).toString(36)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_LABEL: Record<string, string> = {
|
|
22
|
+
terms: 'Terms & Conditions', privacy: 'Privacy Policy', cookies: 'Cookie Policy',
|
|
23
|
+
billing: 'Billing Terms', product: '{{product}} Product Terms',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A minimal stand-in for a renderer's own `resolveLabel` — no i18n context, just the fallback. */
|
|
27
|
+
const label = (doc: ResolvedTermsDocument): string => {
|
|
28
|
+
let text = doc.label ?? DEFAULT_LABEL[doc.key] ?? doc.i18nKey ?? doc.key
|
|
29
|
+
if (doc.params != null) {
|
|
30
|
+
for (const [key, value] of Object.entries(doc.params)) {
|
|
31
|
+
text = text.split(`{{${key}}}`).join(value)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return text
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('resolveTerms — the digest', () => {
|
|
39
|
+
test('is byte-identical to the pre-existing algorithm when nothing new is configured', () => {
|
|
40
|
+
const cfg: LoginTermsConfig = {
|
|
41
|
+
required: true, terms: 'https://a.test/terms', privacy: 'https://a.test/privacy',
|
|
42
|
+
}
|
|
43
|
+
const resolved = resolveTerms(cfg)
|
|
44
|
+
|
|
45
|
+
expect(resolved?.version).toBe(
|
|
46
|
+
legacyDigest(['https://a.test/terms', 'https://a.test/privacy', OWLMEANS_COOKIES_URL])
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a defaulted config (no terms/privacy/cookies at all) matches the same algorithm', () => {
|
|
51
|
+
const resolved = resolveTerms({})
|
|
52
|
+
|
|
53
|
+
expect(resolved?.version).toBe(
|
|
54
|
+
legacyDigest([OWLMEANS_TERMS_URL, OWLMEANS_PRIVACY_URL, OWLMEANS_COOKIES_URL])
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('an explicit version override is used verbatim, as before', () => {
|
|
59
|
+
expect(resolveTerms({ version: 'pinned-v3' })?.version).toBe('pinned-v3')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('adding a billing document changes the digest', () => {
|
|
63
|
+
const base = resolveTerms({})?.version
|
|
64
|
+
const withBilling = resolveTerms({ billing: 'https://a.test/billing' })?.version
|
|
65
|
+
|
|
66
|
+
expect(withBilling).not.toBe(base)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('changing only a revision date changes the digest', () => {
|
|
70
|
+
const unrevised = resolveTerms({})?.version
|
|
71
|
+
const revised = resolveTerms({ revisions: { terms: '2026-01-01' } })?.version
|
|
72
|
+
|
|
73
|
+
expect(revised).not.toBe(unrevised)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('resolveTerms — documents and notices', () => {
|
|
78
|
+
test('billing/product/documents/revisions all populate `documents`, in order', () => {
|
|
79
|
+
// Module-augmentation proof: this whole literal type-checks against `LoginTermsConfig` only
|
|
80
|
+
// because `@owlmeans/client-auth/login`'s augmentation merged onto it — a file that imports
|
|
81
|
+
// NOTHING from this package would fail to compile the `billing`/`product`/`documents`/
|
|
82
|
+
// `revisions`/`showRevision` fields below.
|
|
83
|
+
const cfg: LoginTermsConfig = {
|
|
84
|
+
terms: 'https://a.test/terms',
|
|
85
|
+
billing: { href: 'https://a.test/billing', revisedAt: '2026-02-01' },
|
|
86
|
+
product: { name: 'Acme', href: 'https://a.test/product' },
|
|
87
|
+
documents: [
|
|
88
|
+
{ key: 'custom-a', href: 'https://a.test/custom-a', label: 'Custom A' },
|
|
89
|
+
{ key: 'custom-b', href: 'https://a.test/custom-b', i18nKey: 'login.terms.custom-b' },
|
|
90
|
+
],
|
|
91
|
+
revisions: { terms: '2026-01-15' },
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resolved = resolveTerms(cfg)
|
|
95
|
+
|
|
96
|
+
expect(resolved?.documents.map(doc => doc.key))
|
|
97
|
+
.toEqual(['terms', 'billing', 'product', 'custom-a', 'custom-b'])
|
|
98
|
+
expect(resolved?.documents[0].revisedAt).toBe('2026-01-15')
|
|
99
|
+
expect(resolved?.documents[1].href).toBe('https://a.test/billing')
|
|
100
|
+
expect(resolved?.documents[1].revisedAt).toBe('2026-02-01')
|
|
101
|
+
expect(resolved?.documents[2].href).toBe('https://a.test/product')
|
|
102
|
+
expect(resolved?.documents[2].params).toEqual({ product: 'Acme' })
|
|
103
|
+
expect(resolved?.documents[3]).toMatchObject({ key: 'custom-a', href: 'https://a.test/custom-a', label: 'Custom A' })
|
|
104
|
+
expect(resolved?.documents[4]).toMatchObject({ key: 'custom-b', href: 'https://a.test/custom-b', i18nKey: 'login.terms.custom-b' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a bare billing string is treated as `{ href }`', () => {
|
|
108
|
+
const resolved = resolveTerms({ billing: 'https://a.test/billing' })
|
|
109
|
+
|
|
110
|
+
expect(resolved?.documents.find(doc => doc.key === 'billing')?.href).toBe('https://a.test/billing')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('`false` turns billing/product off — the default', () => {
|
|
114
|
+
const resolved = resolveTerms({ billing: false, product: false })
|
|
115
|
+
|
|
116
|
+
expect(resolved?.documents.map(doc => doc.key)).toEqual(['terms'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('the cookies notice is included when explicitly configured', () => {
|
|
120
|
+
const resolved = resolveTerms({
|
|
121
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy', 'cookies'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('the cookies notice is included when terms/privacy/cookies are ALL still the defaults', () => {
|
|
128
|
+
const resolved = resolveTerms({})
|
|
129
|
+
|
|
130
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy', 'cookies'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('the cookies notice is DROPPED when terms/privacy were customised but cookies was not', () => {
|
|
134
|
+
const resolved = resolveTerms({ terms: 'https://a.test/terms', privacy: 'https://a.test/privacy' })
|
|
135
|
+
|
|
136
|
+
expect(resolved?.notices.map(doc => doc.key)).toEqual(['privacy'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('`revisedAt` is set only when `showRevision` is true', () => {
|
|
140
|
+
const cfg: LoginTermsConfig = { revisions: { terms: '2026-03-01' } }
|
|
141
|
+
|
|
142
|
+
expect(resolveTerms(cfg)?.revisedAt).toBeUndefined()
|
|
143
|
+
expect(resolveTerms({ ...cfg, showRevision: true })?.revisedAt).toBe('2026-03-01')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('`revisedAt` is the LATEST revision among documents', () => {
|
|
147
|
+
const cfg: LoginTermsConfig = {
|
|
148
|
+
showRevision: true,
|
|
149
|
+
billing: { href: 'https://a.test/billing', revisedAt: '2026-01-01' },
|
|
150
|
+
revisions: { terms: '2026-06-01' },
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
expect(resolveTerms(cfg)?.revisedAt).toBe('2026-06-01')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('termsSentence', () => {
|
|
158
|
+
test('interpolates {{documents}} as a formatted, linked list', () => {
|
|
159
|
+
const resolved = resolveTerms({
|
|
160
|
+
terms: 'https://a.test/terms', billing: 'https://a.test/billing',
|
|
161
|
+
})!
|
|
162
|
+
|
|
163
|
+
const parts = termsSentence('I agree to the {{documents}}.', resolved, 'en', label)
|
|
164
|
+
const linked = parts.filter(part => part.href != null)
|
|
165
|
+
|
|
166
|
+
expect(linked.map(part => part.documentKey)).toEqual(['terms', 'billing'])
|
|
167
|
+
expect(parts[0]).toEqual({ text: 'I agree to the ' })
|
|
168
|
+
expect(parts.at(-1)).toEqual({ text: '.' })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('interpolates {{notices}} the same way, from `resolved.notices`', () => {
|
|
172
|
+
const resolved = resolveTerms({
|
|
173
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
174
|
+
})!
|
|
175
|
+
|
|
176
|
+
const parts = termsSentence('Data: {{notices}}.', resolved, 'en', label)
|
|
177
|
+
const linked = parts.filter(part => part.href != null)
|
|
178
|
+
|
|
179
|
+
expect(linked.map(part => part.documentKey)).toEqual(['privacy', 'cookies'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('supports the legacy {{terms}}/{{privacy}}/{{cookies}} placeholders', () => {
|
|
183
|
+
const resolved = resolveTerms({
|
|
184
|
+
terms: 'https://a.test/terms', privacy: 'https://a.test/privacy', cookies: 'https://a.test/cookies',
|
|
185
|
+
})!
|
|
186
|
+
|
|
187
|
+
const parts = termsSentence('{{terms}} and {{privacy}} and {{cookies}}', resolved, 'en', label)
|
|
188
|
+
const linked = parts.filter(part => part.href != null)
|
|
189
|
+
|
|
190
|
+
expect(linked.map(part => part.documentKey)).toEqual(['terms', 'privacy', 'cookies'])
|
|
191
|
+
expect(linked.map(part => part.href))
|
|
192
|
+
.toEqual(['https://a.test/terms', 'https://a.test/privacy', 'https://a.test/cookies'])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a single document renders with no list separator', () => {
|
|
196
|
+
const resolved = resolveTerms({ terms: 'https://a.test/terms' })!
|
|
197
|
+
|
|
198
|
+
const parts = termsSentence('{{documents}}', resolved, 'en', label)
|
|
199
|
+
|
|
200
|
+
expect(parts).toEqual([{ text: 'Terms & Conditions', href: 'https://a.test/terms', documentKey: 'terms' }])
|
|
201
|
+
})
|
|
202
|
+
})
|