@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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/client-auth/SKILL.md +10 -7
  4. package/build/components/dispatcher/component.d.ts.map +1 -1
  5. package/build/components/dispatcher/component.js +19 -12
  6. package/build/components/dispatcher/component.js.map +1 -1
  7. package/build/login/i18n/be.json +6 -2
  8. package/build/login/i18n/de.json +6 -2
  9. package/build/login/i18n/en.json +6 -2
  10. package/build/login/i18n/es.json +6 -2
  11. package/build/login/i18n/fr.json +6 -2
  12. package/build/login/i18n/pl.json +6 -2
  13. package/build/login/i18n/ru.json +6 -2
  14. package/build/login/i18n/uk.json +6 -2
  15. package/build/login/index.d.ts +2 -0
  16. package/build/login/index.d.ts.map +1 -1
  17. package/build/login/index.js +2 -0
  18. package/build/login/index.js.map +1 -1
  19. package/build/login/land.d.ts +44 -0
  20. package/build/login/land.d.ts.map +1 -0
  21. package/build/login/land.js +158 -0
  22. package/build/login/land.js.map +1 -0
  23. package/build/login/screen.d.ts.map +1 -1
  24. package/build/login/screen.js +27 -2
  25. package/build/login/screen.js.map +1 -1
  26. package/build/login/service.d.ts.map +1 -1
  27. package/build/login/service.js +20 -0
  28. package/build/login/service.js.map +1 -1
  29. package/build/login/terms-config.d.ts +59 -0
  30. package/build/login/terms-config.d.ts.map +1 -0
  31. package/build/login/terms-config.js +2 -0
  32. package/build/login/terms-config.js.map +1 -0
  33. package/build/login/terms.d.ts +38 -0
  34. package/build/login/terms.d.ts.map +1 -1
  35. package/build/login/terms.js +0 -0
  36. package/build/login/terms.js.map +1 -1
  37. package/build/login/types.d.ts +77 -0
  38. package/build/login/types.d.ts.map +1 -1
  39. package/package.json +22 -22
  40. package/src/components/dispatcher/component.tsx +25 -12
  41. package/src/login/i18n/be.json +6 -2
  42. package/src/login/i18n/de.json +6 -2
  43. package/src/login/i18n/en.json +6 -2
  44. package/src/login/i18n/es.json +6 -2
  45. package/src/login/i18n/fr.json +6 -2
  46. package/src/login/i18n/pl.json +6 -2
  47. package/src/login/i18n/ru.json +6 -2
  48. package/src/login/i18n/uk.json +6 -2
  49. package/src/login/index.ts +2 -0
  50. package/src/login/land.ts +186 -0
  51. package/src/login/screen.tsx +58 -14
  52. package/src/login/service.ts +26 -2
  53. package/src/login/terms-config.ts +49 -0
  54. package/src/login/terms.ts +0 -0
  55. package/src/login/types.ts +82 -0
  56. package/src/manager/README.md +1 -1
  57. package/tests/i18n.spec.ts +68 -0
  58. package/tests/land.spec.ts +241 -0
  59. 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
+ })