@owlmeans/client-auth 0.1.18-rc.9 → 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.
Files changed (255) hide show
  1. package/README.md +283 -37
  2. package/agent-meta/manifest.json +50 -4
  3. package/agent-meta/skills/client-auth/SKILL.md +84 -55
  4. package/agent-meta/skills/client-auth/parts/login.md +77 -0
  5. package/agent-meta/skills/client-auth/parts/manager.md +37 -0
  6. package/build/components/dispatcher/component.d.ts.map +1 -1
  7. package/build/components/dispatcher/component.js +26 -6
  8. package/build/components/dispatcher/component.js.map +1 -1
  9. package/build/components/dispatcher/types.local.d.ts +7 -0
  10. package/build/components/dispatcher/types.local.d.ts.map +1 -0
  11. package/build/components/dispatcher/types.local.js +2 -0
  12. package/build/components/dispatcher/types.local.js.map +1 -0
  13. package/build/entrypoints.d.ts +7 -0
  14. package/build/entrypoints.d.ts.map +1 -0
  15. package/build/entrypoints.js +7 -0
  16. package/build/entrypoints.js.map +1 -0
  17. package/build/helper.d.ts +2 -1
  18. package/build/helper.d.ts.map +1 -1
  19. package/build/helper.js +28 -5
  20. package/build/helper.js.map +1 -1
  21. package/build/index.d.ts +1 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +1 -1
  24. package/build/index.js.map +1 -1
  25. package/build/login/adopt/types.d.ts +26 -0
  26. package/build/login/adopt/types.d.ts.map +1 -0
  27. package/build/login/adopt/types.js +2 -0
  28. package/build/login/adopt/types.js.map +1 -0
  29. package/build/login/adopt.d.ts +7 -12
  30. package/build/login/adopt.d.ts.map +1 -1
  31. package/build/login/adopt.js +15 -14
  32. package/build/login/adopt.js.map +1 -1
  33. package/build/login/consts.d.ts +89 -4
  34. package/build/login/consts.d.ts.map +1 -1
  35. package/build/login/consts.js +93 -5
  36. package/build/login/consts.js.map +1 -1
  37. package/build/login/consts.local.d.ts +16 -0
  38. package/build/login/consts.local.d.ts.map +1 -0
  39. package/build/login/consts.local.js +30 -0
  40. package/build/login/consts.local.js.map +1 -0
  41. package/build/login/credit/types.d.ts +7 -0
  42. package/build/login/credit/types.d.ts.map +1 -0
  43. package/build/login/credit/types.js +2 -0
  44. package/build/login/credit/types.js.map +1 -0
  45. package/build/login/credit.d.ts +19 -0
  46. package/build/login/credit.d.ts.map +1 -0
  47. package/build/login/credit.js +58 -0
  48. package/build/login/credit.js.map +1 -0
  49. package/build/login/env/types.d.ts +35 -0
  50. package/build/login/env/types.d.ts.map +1 -0
  51. package/build/login/env/types.js +2 -0
  52. package/build/login/env/types.js.map +1 -0
  53. package/build/login/env.d.ts +5 -29
  54. package/build/login/env.d.ts.map +1 -1
  55. package/build/login/env.js +63 -80
  56. package/build/login/env.js.map +1 -1
  57. package/build/login/flow.d.ts +20 -0
  58. package/build/login/flow.d.ts.map +1 -0
  59. package/build/login/flow.js +39 -0
  60. package/build/login/flow.js.map +1 -0
  61. package/build/login/hook.d.ts +30 -4
  62. package/build/login/hook.d.ts.map +1 -1
  63. package/build/login/hook.js +53 -18
  64. package/build/login/hook.js.map +1 -1
  65. package/build/login/i18n/be.json +41 -0
  66. package/build/login/i18n/de.json +41 -0
  67. package/build/login/i18n/en.json +41 -0
  68. package/build/login/i18n/es.json +41 -0
  69. package/build/login/i18n/fr.json +41 -0
  70. package/build/login/i18n/pl.json +41 -0
  71. package/build/login/i18n/ru.json +41 -0
  72. package/build/login/i18n/uk.json +41 -0
  73. package/build/login/i18n.d.ts +2 -0
  74. package/build/login/i18n.d.ts.map +1 -0
  75. package/build/login/i18n.js +27 -0
  76. package/build/login/i18n.js.map +1 -0
  77. package/build/login/index.d.ts +20 -0
  78. package/build/login/index.d.ts.map +1 -1
  79. package/build/login/index.js +11 -0
  80. package/build/login/index.js.map +1 -1
  81. package/build/login/land/types.d.ts +52 -0
  82. package/build/login/land/types.d.ts.map +1 -0
  83. package/build/login/land/types.js +2 -0
  84. package/build/login/land/types.js.map +1 -0
  85. package/build/login/land.d.ts +6 -0
  86. package/build/login/land.d.ts.map +1 -0
  87. package/build/login/land.js +122 -0
  88. package/build/login/land.js.map +1 -0
  89. package/build/login/methods/types.d.ts +30 -0
  90. package/build/login/methods/types.d.ts.map +1 -0
  91. package/build/login/methods/types.js +2 -0
  92. package/build/login/methods/types.js.map +1 -0
  93. package/build/login/methods.d.ts +7 -0
  94. package/build/login/methods.d.ts.map +1 -0
  95. package/build/login/methods.js +87 -0
  96. package/build/login/methods.js.map +1 -0
  97. package/build/login/resume/types.d.ts +24 -0
  98. package/build/login/resume/types.d.ts.map +1 -0
  99. package/build/login/resume/types.js +2 -0
  100. package/build/login/resume/types.js.map +1 -0
  101. package/build/login/resume.d.ts +9 -0
  102. package/build/login/resume.d.ts.map +1 -0
  103. package/build/login/resume.js +38 -0
  104. package/build/login/resume.js.map +1 -0
  105. package/build/login/screen.d.ts +17 -0
  106. package/build/login/screen.d.ts.map +1 -0
  107. package/build/login/screen.js +66 -0
  108. package/build/login/screen.js.map +1 -0
  109. package/build/login/service.d.ts +6 -5
  110. package/build/login/service.d.ts.map +1 -1
  111. package/build/login/service.js +141 -15
  112. package/build/login/service.js.map +1 -1
  113. package/build/login/start/types.d.ts +29 -0
  114. package/build/login/start/types.d.ts.map +1 -0
  115. package/build/login/start/types.js +2 -0
  116. package/build/login/start/types.js.map +1 -0
  117. package/build/login/start.d.ts +5 -0
  118. package/build/login/start.d.ts.map +1 -0
  119. package/build/login/start.js +36 -0
  120. package/build/login/start.js.map +1 -0
  121. package/build/login/surrogate/types.d.ts +9 -0
  122. package/build/login/surrogate/types.d.ts.map +1 -0
  123. package/build/login/surrogate/types.js +2 -0
  124. package/build/login/surrogate/types.js.map +1 -0
  125. package/build/login/surrogate.d.ts +13 -0
  126. package/build/login/surrogate.d.ts.map +1 -0
  127. package/build/login/surrogate.js +33 -0
  128. package/build/login/surrogate.js.map +1 -0
  129. package/build/login/terms/types.d.ts +112 -0
  130. package/build/login/terms/types.d.ts.map +1 -0
  131. package/build/login/terms/types.js +2 -0
  132. package/build/login/terms/types.js.map +1 -0
  133. package/build/login/terms-config.d.ts +65 -0
  134. package/build/login/terms-config.d.ts.map +1 -0
  135. package/build/login/terms-config.js +2 -0
  136. package/build/login/terms-config.js.map +1 -0
  137. package/build/login/terms.d.ts +21 -0
  138. package/build/login/terms.d.ts.map +1 -0
  139. package/build/login/terms.js +235 -0
  140. package/build/login/terms.js.map +1 -0
  141. package/build/login/types.d.ts +223 -16
  142. package/build/login/types.d.ts.map +1 -1
  143. package/build/login/types.js +1 -16
  144. package/build/login/types.js.map +1 -1
  145. package/build/manager/components/authentication/control.js +3 -3
  146. package/build/manager/components/authentication/control.js.map +1 -1
  147. package/build/manager/components/authentication/types.d.ts +2 -2
  148. package/build/manager/components/authentication/types.d.ts.map +1 -1
  149. package/build/manager/components/tunnel-consumer.js +2 -2
  150. package/build/manager/components/tunnel-consumer.js.map +1 -1
  151. package/build/manager/entrypoints.d.ts +11 -0
  152. package/build/manager/entrypoints.d.ts.map +1 -0
  153. package/build/manager/entrypoints.js +17 -0
  154. package/build/manager/entrypoints.js.map +1 -0
  155. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  156. package/build/manager/plugins/basic-ed25519.js +5 -0
  157. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  158. package/build/manager/plugins/exports.d.ts +3 -0
  159. package/build/manager/plugins/exports.d.ts.map +1 -1
  160. package/build/manager/plugins/exports.js +2 -0
  161. package/build/manager/plugins/exports.js.map +1 -1
  162. package/build/manager/plugins/index.d.ts +2 -5
  163. package/build/manager/plugins/index.d.ts.map +1 -1
  164. package/build/manager/plugins/index.js +9 -1
  165. package/build/manager/plugins/index.js.map +1 -1
  166. package/build/manager/plugins/methods.d.ts +6 -0
  167. package/build/manager/plugins/methods.d.ts.map +1 -0
  168. package/build/manager/plugins/methods.js +65 -0
  169. package/build/manager/plugins/methods.js.map +1 -0
  170. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  171. package/build/manager/plugins/re-captcha.js +5 -0
  172. package/build/manager/plugins/re-captcha.js.map +1 -1
  173. package/build/manager/plugins/registry/types.d.ts +10 -0
  174. package/build/manager/plugins/registry/types.d.ts.map +1 -0
  175. package/build/manager/plugins/registry/types.js +2 -0
  176. package/build/manager/plugins/registry/types.js.map +1 -0
  177. package/build/manager/plugins/registry.d.ts +20 -0
  178. package/build/manager/plugins/registry.d.ts.map +1 -0
  179. package/build/manager/plugins/registry.js +23 -0
  180. package/build/manager/plugins/registry.js.map +1 -0
  181. package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
  182. package/build/manager/plugins/tunnel-consumer.js +10 -3
  183. package/build/manager/plugins/tunnel-consumer.js.map +1 -1
  184. package/build/manager/plugins/types.d.ts +53 -0
  185. package/build/manager/plugins/types.d.ts.map +1 -1
  186. package/build/modules.d.ts.map +1 -1
  187. package/build/service.js +1 -1
  188. package/build/service.js.map +1 -1
  189. package/package.json +33 -30
  190. package/src/components/dispatcher/component.tsx +37 -14
  191. package/src/components/dispatcher/types.local.ts +7 -0
  192. package/src/entrypoints.ts +10 -0
  193. package/src/helper.ts +31 -5
  194. package/src/index.ts +1 -1
  195. package/src/login/adopt/types.ts +25 -0
  196. package/src/login/adopt.ts +21 -14
  197. package/src/login/consts.local.ts +34 -0
  198. package/src/login/consts.ts +105 -5
  199. package/src/login/credit/types.ts +6 -0
  200. package/src/login/credit.ts +69 -0
  201. package/src/login/env/types.ts +35 -0
  202. package/src/login/env.ts +60 -72
  203. package/src/login/flow.ts +44 -0
  204. package/src/login/hook.ts +58 -22
  205. package/src/login/i18n/be.json +41 -0
  206. package/src/login/i18n/de.json +41 -0
  207. package/src/login/i18n/en.json +41 -0
  208. package/src/login/i18n/es.json +41 -0
  209. package/src/login/i18n/fr.json +41 -0
  210. package/src/login/i18n/pl.json +41 -0
  211. package/src/login/i18n/ru.json +41 -0
  212. package/src/login/i18n/uk.json +41 -0
  213. package/src/login/i18n.ts +29 -0
  214. package/src/login/index.ts +21 -0
  215. package/src/login/land/types.ts +54 -0
  216. package/src/login/land.ts +149 -0
  217. package/src/login/methods/types.ts +32 -0
  218. package/src/login/methods.ts +109 -0
  219. package/src/login/resume/types.ts +24 -0
  220. package/src/login/resume.ts +45 -0
  221. package/src/login/screen.tsx +140 -0
  222. package/src/login/service.ts +181 -18
  223. package/src/login/start/types.ts +30 -0
  224. package/src/login/start.ts +44 -0
  225. package/src/login/surrogate/types.ts +9 -0
  226. package/src/login/surrogate.ts +37 -0
  227. package/src/login/terms/types.ts +126 -0
  228. package/src/login/terms-config.ts +55 -0
  229. package/src/login/terms.ts +315 -0
  230. package/src/login/types.ts +233 -17
  231. package/src/manager/README.md +37 -80
  232. package/src/manager/components/authentication/control.ts +3 -3
  233. package/src/manager/components/authentication/types.ts +2 -2
  234. package/src/manager/components/tunnel-consumer.tsx +2 -2
  235. package/src/manager/entrypoints.ts +18 -0
  236. package/src/manager/plugins/basic-ed25519.tsx +7 -0
  237. package/src/manager/plugins/exports.ts +3 -0
  238. package/src/manager/plugins/index.ts +11 -4
  239. package/src/manager/plugins/methods.ts +74 -0
  240. package/src/manager/plugins/re-captcha.tsx +7 -0
  241. package/src/manager/plugins/registry/types.ts +10 -0
  242. package/src/manager/plugins/registry.ts +33 -0
  243. package/src/manager/plugins/tunnel-consumer.tsx +12 -3
  244. package/src/manager/plugins/types.ts +54 -0
  245. package/src/service.ts +1 -1
  246. package/tests/context.ts +1 -2
  247. package/tests/i18n.spec.ts +68 -0
  248. package/tests/land.spec.ts +292 -0
  249. package/tests/login.spec.ts +332 -0
  250. package/tests/target.spec.ts +210 -0
  251. package/tests/terms-deferred.spec.ts +65 -0
  252. package/tests/terms.spec.ts +314 -0
  253. package/build/.gitkeep +0 -0
  254. package/src/manager/modules.ts +0 -18
  255. package/src/modules.ts +0 -13
@@ -0,0 +1,292 @@
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, type Auth } from '@owlmeans/auth'
6
+ import { HOME, type BasicContext, type BasicConfig } from '@owlmeans/context'
7
+ import { createStaticResource } from '@owlmeans/static-resource'
8
+ import type { Resource } from '@owlmeans/resource'
9
+ import { FLOW_STATE, RESUME_FLOW, type SuspendedLandingRecord } from '@owlmeans/client-flow'
10
+ import { DEFAULT_ALIAS as AUTH_ALIAS } from '../src/consts.js'
11
+ import type { AuthService } from '@owlmeans/auth-common'
12
+ import { appendLogin } from '../src/login/service.js'
13
+ import { makeLoginLandingHelper } from '../src/login/land.js'
14
+ import { LOGIN_LANDED_STORAGE } from '../src/login/consts.js'
15
+ import type { LoginLandingHook, LoginService, LoginStep } from '../src/login/types.js'
16
+
17
+ /**
18
+ * A hand-written `window.localStorage`, not jsdom — `land.ts` reads exactly two keys through
19
+ * `try`/`catch`, and a real DOM would prove a fixture behaves rather than that the module does.
20
+ * Mirrors `web-client/tests/surrogate.spec.ts`'s own `stubWindow` idiom.
21
+ */
22
+ const stubStorage = (): void => {
23
+ const store = new Map<string, string>()
24
+ ;(globalThis as unknown as { window: unknown }).window = {
25
+ localStorage: {
26
+ getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
27
+ setItem: (key: string, value: string) => { store.set(key, value) },
28
+ removeItem: (key: string) => { store.delete(key) },
29
+ },
30
+ }
31
+ }
32
+
33
+ afterEach(() => {
34
+ delete (globalThis as { window?: unknown }).window
35
+ })
36
+
37
+ /** A validly-signed bearer, exactly as `server-auth` would issue one. */
38
+ const makeBearer = async (auth: Partial<Auth>): Promise<string> => {
39
+ const appKP = makeFixtureKeyPair('client-auth-land-test-key')
40
+ const fullAuth: Auth = {
41
+ token: 'nonce-123', userId: 'user-1', scopes: ['*'], role: AuthRole.User,
42
+ type: AuthroizationType.Ed25519BasicToken, source: 'test-service', isUser: true,
43
+ createdAt: new Date(), ...auth,
44
+ }
45
+ const signed = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
46
+ .send(fullAuth, null).sign(appKP, EnvelopeKind.Token)
47
+
48
+ return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${signed}`
49
+ }
50
+
51
+ const bootstrap = async (opts?: {
52
+ authenticated?: boolean, withFlowState?: boolean
53
+ }): Promise<[BasicContext<BasicConfig>, LoginService]> => {
54
+ const context = makeTestContext()
55
+ if (opts?.withFlowState) {
56
+ context.registerResource(
57
+ createStaticResource<SuspendedLandingRecord>(FLOW_STATE, 'client-auth-land-tests')
58
+ )
59
+ }
60
+ context.configure()
61
+ await context.init()
62
+
63
+ if (opts?.authenticated) {
64
+ const authService = context.service<AuthService>(AUTH_ALIAS)
65
+ await authService.update(await makeBearer({ userId: 'land-test-user' }))
66
+ }
67
+
68
+ const contextual = appendLogin(context)
69
+
70
+ return [context, contextual.login()]
71
+ }
72
+
73
+ const step = (over: Partial<LoginStep> & { alias: string, entrypoint: string }): LoginStep => ({
74
+ pending: async () => false, ...over,
75
+ })
76
+
77
+ /** Grant a fake alias existence without building a real entrypoint/route graph. */
78
+ const allowEntrypoints = (context: BasicContext<BasicConfig>, ...aliases: string[]): void => {
79
+ (context as unknown as { hasEntrypoint: (alias: string) => boolean }).hasEntrypoint =
80
+ alias => aliases.includes(alias)
81
+ }
82
+
83
+ describe('continueLogin — no steps registered', () => {
84
+ test('falls through to a suspended flow, then HOME', async () => {
85
+ const [context] = await bootstrap({ authenticated: true })
86
+
87
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: HOME })
88
+ })
89
+ })
90
+
91
+ describe('continueLogin — steps', () => {
92
+ test('a pending step lands there, and a suspended landing is NOT consumed', async () => {
93
+ stubStorage()
94
+ const [context, login] = await bootstrap({ authenticated: true, withFlowState: true })
95
+ allowEntrypoints(context, 'step-screen')
96
+
97
+ const resource = context.resource<Resource<SuspendedLandingRecord>>(FLOW_STATE)
98
+ await resource.save({
99
+ id: RESUME_FLOW, entrypoint: 'suspended-screen', query: { ref: 'abc' },
100
+ expiresAt: Date.now() + 60_000,
101
+ })
102
+
103
+ login.registerStep(step({
104
+ alias: 'consent', entrypoint: 'step-screen', pending: async () => true,
105
+ query: () => ({ from: 'test' }),
106
+ }))
107
+
108
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({
109
+ alias: 'step-screen', query: { from: 'test' }, step: 'consent',
110
+ })
111
+ // The suspended landing is still there — a later step, or the eventual fallback, still needs it.
112
+ expect(await resource.load(RESUME_FLOW)).not.toBeNull()
113
+ })
114
+
115
+ test('a step whose `pending` throws is treated as not pending — fail open', async () => {
116
+ const [context, login] = await bootstrap({ authenticated: true })
117
+ allowEntrypoints(context, 'broken-screen')
118
+
119
+ login.registerStep(step({
120
+ alias: 'broken', entrypoint: 'broken-screen',
121
+ pending: async () => { throw new Error('boom') },
122
+ }))
123
+
124
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: HOME })
125
+ })
126
+
127
+ test('a step whose `pending` never resolves is treated as not pending after the timeout', async () => {
128
+ const [context, login] = await bootstrap({ authenticated: true })
129
+ allowEntrypoints(context, 'hanging-screen')
130
+
131
+ login.registerStep(step({
132
+ alias: 'hanging', entrypoint: 'hanging-screen',
133
+ pending: () => new Promise<boolean>(() => { /* never settles */ }),
134
+ }))
135
+
136
+ expect(await makeLoginLandingHelper(context).continueLogin({ stepTimeout: 20 })).toEqual({ alias: HOME })
137
+ })
138
+
139
+ test('a step whose entrypoint is unresolvable is skipped, never throws', async () => {
140
+ const [context, login] = await bootstrap({ authenticated: true })
141
+ let called = false
142
+
143
+ login.registerStep(step({
144
+ alias: 'unbound', entrypoint: 'nowhere', pending: async () => { called = true; return true },
145
+ }))
146
+
147
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: HOME })
148
+ expect(called).toBe(false)
149
+ })
150
+
151
+ test('`after` skips every step up to and including that alias', async () => {
152
+ const [context, login] = await bootstrap({ authenticated: true })
153
+ allowEntrypoints(context, 'a-screen', 'b-screen')
154
+
155
+ login.registerStep(step({ alias: 'a', entrypoint: 'a-screen', priority: 20, pending: async () => true }))
156
+ login.registerStep(step({ alias: 'b', entrypoint: 'b-screen', priority: 10, pending: async () => true }))
157
+
158
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: 'a-screen', query: undefined, step: 'a' })
159
+ expect(await makeLoginLandingHelper(context).continueLogin({ after: 'a' }))
160
+ .toEqual({ alias: 'b-screen', query: undefined, step: 'b' })
161
+ })
162
+
163
+ test('an unauthenticated caller with an explicit alias still gets that alias', async () => {
164
+ const [context] = await bootstrap()
165
+
166
+ expect(await makeLoginLandingHelper(context).continueLogin({
167
+ fallback: { alias: 'explicit-alias' }, resume: false,
168
+ })).toEqual({ alias: 'explicit-alias' })
169
+ })
170
+ })
171
+
172
+ describe('continueLogin — a `required` step', () => {
173
+ test('a `required` step whose `pending` throws is treated as PENDING — fail closed', async () => {
174
+ const [context, login] = await bootstrap({ authenticated: true })
175
+ allowEntrypoints(context, 'required-screen')
176
+
177
+ login.registerStep(step({
178
+ alias: 'required-broken', entrypoint: 'required-screen', required: true,
179
+ pending: async () => { throw new Error('boom') },
180
+ }))
181
+
182
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({
183
+ alias: 'required-screen', query: undefined, step: 'required-broken',
184
+ })
185
+ })
186
+
187
+ test('a `required` step whose `pending` never resolves is treated as PENDING after the timeout', async () => {
188
+ const [context, login] = await bootstrap({ authenticated: true })
189
+ allowEntrypoints(context, 'required-hanging-screen')
190
+
191
+ login.registerStep(step({
192
+ alias: 'required-hanging', entrypoint: 'required-hanging-screen', required: true,
193
+ pending: () => new Promise<boolean>(() => { /* never settles */ }),
194
+ }))
195
+
196
+ expect(await makeLoginLandingHelper(context).continueLogin({ stepTimeout: 20 })).toEqual({
197
+ alias: 'required-hanging-screen', query: undefined, step: 'required-hanging',
198
+ })
199
+ })
200
+
201
+ test('a `required` but UNBOUND step is still skipped, never blocks', async () => {
202
+ const [context, login] = await bootstrap({ authenticated: true })
203
+ let called = false
204
+
205
+ login.registerStep(step({
206
+ alias: 'required-unbound', entrypoint: 'nowhere', required: true,
207
+ pending: async () => { called = true; return true },
208
+ }))
209
+
210
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: HOME })
211
+ expect(called).toBe(false)
212
+ })
213
+
214
+ test('a `required` step that answers false, or true, still behaves ordinarily', async () => {
215
+ const [context, login] = await bootstrap({ authenticated: true })
216
+ allowEntrypoints(context, 'required-screen')
217
+
218
+ login.registerStep(step({
219
+ alias: 'required-ok', entrypoint: 'required-screen', required: true, pending: async () => false,
220
+ }))
221
+ expect(await makeLoginLandingHelper(context).continueLogin()).toEqual({ alias: HOME })
222
+ })
223
+ })
224
+
225
+ describe('landAfterLogin — the surrogate short circuit', () => {
226
+ test('returns DISPATCHER immediately; no step or hook ever runs', async () => {
227
+ const [context, login] = await bootstrap({ authenticated: true })
228
+ login.env = () => ({ hasWindow: true, embedded: true, surrogate: true, hasOpener: true })
229
+
230
+ let stepCalled = false
231
+ let hookCalled = false
232
+ login.registerStep(step({
233
+ alias: 'never', entrypoint: 'never-screen', pending: async () => { stepCalled = true; return true },
234
+ }))
235
+ login.onLanded({ alias: 'never', landed: async () => { hookCalled = true } })
236
+
237
+ expect(await makeLoginLandingHelper(context).landAfterLogin()).toEqual({ alias: DISPATCHER })
238
+ expect(stepCalled).toBe(false)
239
+ expect(hookCalled).toBe(false)
240
+ })
241
+ })
242
+
243
+ describe('landAfterLogin — landing hooks', () => {
244
+ const hook = (over: Partial<LoginLandingHook> & { alias: string }): LoginLandingHook => ({
245
+ landed: async () => { }, ...over,
246
+ })
247
+
248
+ test('run exactly once per distinct token, in priority order, each swallowed on error', async () => {
249
+ stubStorage()
250
+ const [context, login] = await bootstrap({ authenticated: true })
251
+
252
+ const order: string[] = []
253
+ login.onLanded(hook({ alias: 'low', priority: 0, landed: async () => { order.push('low') } }))
254
+ login.onLanded(hook({
255
+ alias: 'high', priority: 10, landed: async () => { order.push('high'); throw new Error('boom') },
256
+ }))
257
+
258
+ await makeLoginLandingHelper(context).landAfterLogin()
259
+ expect(order).toEqual(['high', 'low'])
260
+
261
+ // Same token again — neither hook runs a second time.
262
+ await makeLoginLandingHelper(context).landAfterLogin()
263
+ expect(order).toEqual(['high', 'low'])
264
+ })
265
+
266
+ test('a distinct token runs the hooks again', async () => {
267
+ stubStorage()
268
+ const [context, login] = await bootstrap({ authenticated: true })
269
+
270
+ let count = 0
271
+ login.onLanded(hook({ alias: 'counter', landed: async () => { count += 1 } }))
272
+
273
+ await makeLoginLandingHelper(context).landAfterLogin()
274
+ expect(count).toBe(1)
275
+
276
+ const authService = context.service<AuthService>(AUTH_ALIAS)
277
+ await authService.update(await makeBearer({ userId: 'a-different-user' }))
278
+
279
+ await makeLoginLandingHelper(context).landAfterLogin()
280
+ expect(count).toBe(2)
281
+ })
282
+
283
+ test('storage records the raw token, not a digest, under LOGIN_LANDED_STORAGE', async () => {
284
+ stubStorage()
285
+ const [context] = await bootstrap({ authenticated: true })
286
+ const authService = context.service<AuthService>(AUTH_ALIAS)
287
+
288
+ await makeLoginLandingHelper(context).landAfterLogin()
289
+
290
+ expect(window.localStorage.getItem(LOGIN_LANDED_STORAGE)).toBe(authService.token as string)
291
+ })
292
+ })
@@ -0,0 +1,332 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { makeTestContext } from './context.js'
3
+ import { appendLogin, makeLoginService } from '../src/login/service.js'
4
+ import { loginMethodsHelper } from '../src/login/methods.js'
5
+ import { resolveCredit } from '../src/login/credit.js'
6
+ import { pluginMethodSource } from '../src/manager/plugins/methods.js'
7
+ import { authPluginHelper } from '../src/manager/plugins/registry.js'
8
+ import type { AuthenticationPlugin } from '../src/manager/plugins/types.js'
9
+ import { loginResumeHelper } from '../src/login/resume.js'
10
+ import { ResumeAction, LoginOutcome } from '../src/login/consts.js'
11
+ import type {
12
+ LoginEnv, LoginMethod, LoginMethodContext, LoginPlugin, LoginService,
13
+ } from '../src/login/types.js'
14
+ import type { BasicContext, BasicConfig } from '@owlmeans/context'
15
+
16
+ /**
17
+ * A hand-written environment descriptor.
18
+ *
19
+ * `LoginEnv` exists precisely so a plugin's decisions can be exercised without a DOM — a stubbed
20
+ * `window` would prove that the stub behaves, not that the cascade does.
21
+ */
22
+ const env = (over?: Partial<LoginEnv>): LoginEnv => ({
23
+ hasWindow: true, embedded: false, surrogate: false, hasOpener: false, ...over,
24
+ })
25
+
26
+ const stubPlugin = (over: Partial<LoginPlugin> & { alias: string }): LoginPlugin => ({
27
+ begin: async () => LoginOutcome.Passed,
28
+ authorize: async () => LoginOutcome.Passed,
29
+ complete: async () => LoginOutcome.Passed,
30
+ ...over,
31
+ })
32
+
33
+ const bootstrap = (): [BasicContext<BasicConfig>, LoginService] => {
34
+ const context = makeTestContext()
35
+ const contextual = appendLogin(context)
36
+
37
+ return [context, contextual.login()]
38
+ }
39
+
40
+ const methodCtx = (context: BasicContext<BasicConfig>, over?: Partial<LoginEnv>): LoginMethodContext =>
41
+ ({ context, env: env(over) })
42
+
43
+ const method = (over: Partial<LoginMethod> & { id: string }): LoginMethod => ({
44
+ start: async () => LoginOutcome.Passed, ...over,
45
+ })
46
+
47
+ describe('login plugin host', () => {
48
+ test('the cascade picks the highest-priority plugin whose match holds', () => {
49
+ const service = makeLoginService()
50
+ service.registerPlugin(stubPlugin({ alias: 'default', priority: 0, match: () => true }))
51
+ service.registerPlugin(stubPlugin({
52
+ alias: 'framed', priority: 100, match: candidate => candidate.embedded,
53
+ }))
54
+
55
+ expect(service.plugin(env()).alias).toBe('default')
56
+ expect(service.plugin(env({ embedded: true })).alias).toBe('framed')
57
+ })
58
+
59
+ test('a plugin with no resume yields Passed, so an ordinary tab is untouched', async () => {
60
+ const [, service] = bootstrap()
61
+ service.registerPlugin(stubPlugin({ alias: 'plain', match: () => true }))
62
+
63
+ expect(await service.resume('a-token')).toBe(LoginOutcome.Passed)
64
+ })
65
+
66
+ test('a plugin with a resume is asked instead', async () => {
67
+ const [, service] = bootstrap()
68
+ service.registerPlugin(stubPlugin({
69
+ alias: 'framed', match: () => true, resume: async () => LoginOutcome.Handled,
70
+ }))
71
+
72
+ expect(await service.resume('a-token')).toBe(LoginOutcome.Handled)
73
+ })
74
+
75
+ test('a refusing precondition stops the flow before any plugin runs', async () => {
76
+ const [, service] = bootstrap()
77
+ let started = false
78
+ service.registerPlugin(stubPlugin({
79
+ alias: 'plain', match: () => true,
80
+ begin: async () => { started = true; return LoginOutcome.Handled },
81
+ }))
82
+ service.registerPrecondition({ alias: 'refuse', check: () => false })
83
+
84
+ expect(await service.begin({ url: '/dispatcher' })).toBe(LoginOutcome.Gesture)
85
+ expect(started).toBe(false)
86
+ })
87
+
88
+ test('a passing precondition lets the plugin run', async () => {
89
+ const [, service] = bootstrap()
90
+ service.registerPlugin(stubPlugin({
91
+ alias: 'plain', match: () => true, begin: async () => LoginOutcome.Handled,
92
+ }))
93
+ service.registerPrecondition({ alias: 'allow', check: () => true })
94
+
95
+ expect(await service.begin({ url: '/dispatcher' })).toBe(LoginOutcome.Handled)
96
+ })
97
+
98
+ test('a registered notifier hears the settled outcome of begin and logout', async () => {
99
+ const [, service] = bootstrap()
100
+ service.registerPlugin(stubPlugin({
101
+ alias: 'blocking', match: () => true,
102
+ begin: async () => LoginOutcome.Blocked,
103
+ logout: async () => LoginOutcome.Blocked,
104
+ }))
105
+ const seen: LoginOutcome[] = []
106
+ service.registerNotifier(outcome => { seen.push(outcome) })
107
+
108
+ expect(await service.begin({ url: '/dispatcher' })).toBe(LoginOutcome.Blocked)
109
+ expect(await service.logout({ url: '/dispatcher' })).toBe(LoginOutcome.Blocked)
110
+ expect(seen).toEqual([LoginOutcome.Blocked, LoginOutcome.Blocked])
111
+ })
112
+
113
+ test('a plugin with no logout still ends the session it was asked to end', async () => {
114
+ const [context, service] = bootstrap()
115
+ context.configure()
116
+ await context.init()
117
+ service.registerPlugin(stubPlugin({ alias: 'plain', match: () => true }))
118
+ const auth = context.service<{ token?: string, update: (t?: string) => Promise<void> }>('auth')
119
+
120
+ expect(await service.logout({ url: '' })).toBe(LoginOutcome.Passed)
121
+ expect(auth.token == null || auth.token === '').toBe(true)
122
+ })
123
+ })
124
+
125
+ describe('login method resolution', () => {
126
+ test('methods sort by declared order, ties by id', () => {
127
+ const context = makeTestContext()
128
+ const source = {
129
+ alias: 'test', list: () => [
130
+ method({ id: 'b', order: 10 }), method({ id: 'a', order: 10 }), method({ id: 'z', order: 1 }),
131
+ ],
132
+ }
133
+
134
+ const resolved = loginMethodsHelper.resolveLoginMethods(methodCtx(context), undefined, [source])
135
+
136
+ expect(resolved.map(item => item.id)).toEqual(['z', 'a', 'b'])
137
+ })
138
+
139
+ test('a restricted method is not offered until the configuration asks for it', () => {
140
+ const context = makeTestContext()
141
+ const source = {
142
+ alias: 'test', list: () => [method({ id: 'ordinary' }), method({ id: 'operator', restricted: true })],
143
+ }
144
+
145
+ expect(loginMethodsHelper.resolveLoginMethods(methodCtx(context), undefined, [source]).map(m => m.id))
146
+ .toEqual(['ordinary'])
147
+ expect(loginMethodsHelper.resolveLoginMethods(
148
+ methodCtx(context), { overrides: { operator: { enabled: true } } }, [source]
149
+ ).map(m => m.id).sort()).toEqual(['operator', 'ordinary'])
150
+ expect(loginMethodsHelper.resolveLoginMethods(
151
+ methodCtx(context), { methods: ['operator'] }, [source]
152
+ ).map(m => m.id)).toEqual(['operator'])
153
+ })
154
+
155
+ test('an explicit list is both the filter and the order', () => {
156
+ const context = makeTestContext()
157
+ const source = {
158
+ alias: 'test', list: () => [method({ id: 'a' }), method({ id: 'b' }), method({ id: 'c' })],
159
+ }
160
+
161
+ expect(loginMethodsHelper.resolveLoginMethods(methodCtx(context), { methods: ['c', 'a'] }, [source]).map(m => m.id))
162
+ .toEqual(['c', 'a'])
163
+ })
164
+
165
+ test('a source that throws is skipped rather than taking the screen down', () => {
166
+ const context = makeTestContext()
167
+ const broken = { alias: 'broken', list: () => { throw new Error('misconfigured') } }
168
+ const working = { alias: 'working', list: () => [method({ id: 'ok' })] }
169
+
170
+ expect(loginMethodsHelper.resolveLoginMethods(methodCtx(context), undefined, [broken, working]).map(m => m.id))
171
+ .toEqual(['ok'])
172
+ })
173
+
174
+ test('the primary method is the first emphasised one, else the first', () => {
175
+ expect(loginMethodsHelper.primaryLoginMethod([
176
+ method({ id: 'a' }), method({ id: 'b', emphasis: 'primary' }),
177
+ ])?.id).toBe('b')
178
+ expect(loginMethodsHelper.primaryLoginMethod([method({ id: 'a' })])?.id).toBe('a')
179
+ expect(loginMethodsHelper.primaryLoginMethod([])).toBeNull()
180
+ })
181
+
182
+ test('a globally registered source reaches the resolver', () => {
183
+ const context = makeTestContext()
184
+ loginMethodsHelper.registerMethodSource({ alias: 'global-test', list: () => [method({ id: 'global' })] })
185
+
186
+ expect(loginMethodsHelper.resolveLoginMethods(methodCtx(context)).map(m => m.id)).toContain('global')
187
+ })
188
+ })
189
+
190
+ describe('the credit line', () => {
191
+ const year = new Date().getFullYear()
192
+
193
+ test('is a copyright NOTICE — the mark and the year, not a bare name', () => {
194
+ expect(resolveCredit({ product: 'OwlMeans', organization: 'OwlMeans' }).line)
195
+ .toBe(`© ${year} OwlMeans`)
196
+ })
197
+
198
+ test('pairs the product with the notice when the product is not the holder', () => {
199
+ expect(resolveCredit({ product: 'Taskly', organization: 'Acme' }).line)
200
+ .toBe(`Taskly — © ${year} Acme`)
201
+ })
202
+
203
+ test('falls back to the entity slug when the organization has no name', () => {
204
+ expect(resolveCredit({ product: 'Taskly', entity: 'acme-7f2' }).line)
205
+ .toBe(`Taskly — © ${year} acme-7f2`)
206
+ })
207
+
208
+ test('a first year in the past makes the notice a range', () => {
209
+ expect(resolveCredit({ organization: 'Acme', since: 2019 }).line).toBe(`© 2019–${year} Acme`)
210
+ // The current year is not a range, and neither is one a skewed clock puts ahead of it.
211
+ expect(resolveCredit({ organization: 'Acme', since: year }).line).toBe(`© ${year} Acme`)
212
+ expect(resolveCredit({ organization: 'Acme', since: year + 5 }).line).toBe(`© ${year} Acme`)
213
+ })
214
+
215
+ test('an explicit holder is who the notice names', () => {
216
+ expect(resolveCredit({ product: 'Taskly', organization: 'Taskly', holder: 'Acme Inc.' }).line)
217
+ .toBe(`Taskly — © ${year} Acme Inc.`)
218
+ })
219
+
220
+ test('an owner\'s own wording is used verbatim', () => {
221
+ expect(resolveCredit({ organization: 'Acme', copyright: '© Acme. All rights reserved.' }).line)
222
+ .toBe('© Acme. All rights reserved.')
223
+ })
224
+
225
+ test('switching the notice off leaves the bare pairing it used to be', () => {
226
+ expect(resolveCredit({ product: 'Taskly', organization: 'Acme', copyright: false }).line)
227
+ .toBe('Taskly — Acme')
228
+ expect(resolveCredit({ product: 'Taskly', copyright: false }).line).toBe('Taskly')
229
+ })
230
+
231
+ test('a literal line replaces the composition entirely', () => {
232
+ expect(resolveCredit({ product: 'Taskly', organization: 'Acme', line: '© Acme' }).line)
233
+ .toBe('© Acme')
234
+ })
235
+
236
+ test('nobody to name is nothing to render', () => {
237
+ expect(resolveCredit().line).toBeNull()
238
+ })
239
+
240
+ test('an EMPTY value is unset, not a value', () => {
241
+ // Exactly what a platform delivers for branding it does not have: every key present, the
242
+ // undelivered ones empty. `??` does not fall through an empty string, so the organization
243
+ // used to win the chain and compose "pisteva — " with nothing after the dash.
244
+ expect(resolveCredit({}, { name: 'pisteva', organization: '', entity: '' }).line)
245
+ .toBe(`© ${year} pisteva`)
246
+ expect(resolveCredit({ line: '', product: '', organization: '' }, { name: 'Taskly' }).line)
247
+ .toBe(`© ${year} Taskly`)
248
+ expect(resolveCredit({ copyright: ' ' }, { name: 'Taskly' }).line).toBe(`© ${year} Taskly`)
249
+ expect(resolveCredit({}, {}, '').line).toBeNull()
250
+ })
251
+
252
+ test('the platform credit is on unless it is switched off', () => {
253
+ expect(resolveCredit().poweredBy).toBe(true)
254
+ expect(resolveCredit({ poweredBy: false }).poweredBy).toBe(false)
255
+ })
256
+ })
257
+
258
+ describe('the authentication-plugin method source', () => {
259
+ const plugin = (
260
+ over: Partial<AuthenticationPlugin> & { type: string }
261
+ ): AuthenticationPlugin => ({ Implementation: () => () => null, ...over })
262
+
263
+ const offered = (context: BasicContext<BasicConfig>): string[] =>
264
+ pluginMethodSource.list(methodCtx(context)).map(item => item.id)
265
+
266
+ test('a plugin that declares no method is not a way to sign in', () => {
267
+ const context = makeTestContext()
268
+ authPluginHelper.registerAuthPlugin(plugin({ type: 'step-in-a-flow' }))
269
+ authPluginHelper.registerAuthPlugin(plugin({ type: 'offerable', method: {} }))
270
+
271
+ expect(offered(context)).toContain('offerable')
272
+ expect(offered(context)).not.toContain('step-in-a-flow')
273
+ })
274
+
275
+ test('a plugin that needs a renderer nobody assigned is left off the screen', () => {
276
+ const context = makeTestContext()
277
+ // Exactly the state an app is in when it imported the plugin host but not a panel package:
278
+ // the plugin is registered, and mounting its screen throws.
279
+ authPluginHelper.registerAuthPlugin(plugin({ type: 'needs-ui', method: {}, requiresRenderer: true }))
280
+
281
+ expect(offered(context)).not.toContain('needs-ui')
282
+
283
+ authPluginHelper.registerAuthPlugin(plugin({
284
+ type: 'needs-ui', method: {}, requiresRenderer: true, Renderer: () => null,
285
+ }))
286
+
287
+ expect(offered(context)).toContain('needs-ui')
288
+ })
289
+
290
+ test('a plugin whose own wiring is absent says so and is left off', () => {
291
+ const context = makeTestContext()
292
+ authPluginHelper.registerAuthPlugin(plugin({
293
+ type: 'needs-wiring', method: { available: ctx => ctx.context.hasService('never-appended') },
294
+ }))
295
+
296
+ expect(offered(context)).not.toContain('needs-wiring')
297
+ })
298
+ })
299
+
300
+ describe('a finished sign-in attempt', () => {
301
+ test('reports every outcome that leaves the user on the screen', () => {
302
+ // The regression: a method that could not build an authorization URL returned `Passed`, the
303
+ // screen rendered nothing, and the button read as broken. `Passed` is a valid answer TO A
304
+ // DISPATCHER, which has a continuation; a screen does not.
305
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Passed)).toBe('login.error.failed')
306
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Failed)).toBe('login.error.failed')
307
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Gesture)).toBe('login.error.blocked')
308
+ // A `window.open` the browser actually refused reads the same as "needs a fresh gesture" —
309
+ // both are cured by clicking the browser's own blocked-popup affordance, not by a retry loop.
310
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Blocked)).toBe('login.error.blocked')
311
+ })
312
+
313
+ test('says nothing when the attempt actually went somewhere', () => {
314
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Handled)).toBeNull()
315
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Redirected)).toBeNull()
316
+ expect(loginResumeHelper.loginAttemptError(LoginOutcome.Orphaned)).toBeNull()
317
+ expect(loginResumeHelper.loginAttemptError(null)).toBeNull()
318
+ })
319
+ })
320
+
321
+ describe('resume outcomes', () => {
322
+ test('map to exactly one action each', () => {
323
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Handled)).toBe(ResumeAction.Stop)
324
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Redirected)).toBe(ResumeAction.Stop)
325
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Orphaned)).toBe(ResumeAction.Render)
326
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Failed)).toBe(ResumeAction.Render)
327
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Gesture)).toBe(ResumeAction.Render)
328
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Blocked)).toBe(ResumeAction.Render)
329
+ // The ordinary tab: keep the session and carry on. This is the one that must never change.
330
+ expect(loginResumeHelper.resumeAction(LoginOutcome.Passed)).toBe(ResumeAction.Navigate)
331
+ })
332
+ })