@avelonjs/conformance 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avelonjs/conformance",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Contract test suites and in-memory fakes for Avelon drivers.",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import {
4
4
  Unauthenticated,
5
5
  type IdentityDriver,
6
6
  type IdentityOrganization,
7
+ type EmailVerificationIdentitySurface,
7
8
  type MagicLinkIdentitySurface,
8
9
  type MfaChallenge,
9
10
  type MfaIdentitySurface,
@@ -19,6 +20,7 @@ const capabilities = {
19
20
  oauth: true,
20
21
  organizations: true,
21
22
  mfa: ['totp', 'recovery'] as const,
23
+ emailVerification: true,
22
24
  } as const
23
25
 
24
26
  type Factor = (typeof capabilities.mfa)[number]
@@ -43,6 +45,7 @@ export interface FakeIdentitySession {
43
45
 
44
46
  interface StoredActor extends FakeIdentityActor {
45
47
  password: string
48
+ emailVerified: boolean
46
49
  }
47
50
 
48
51
  interface StoredSession extends FakeIdentitySession {
@@ -73,10 +76,11 @@ export class FakeIdentity
73
76
  implements
74
77
  IdentityDriver<typeof capabilities, FakeIdentityRaw, FakeIdentityActor, FakeIdentitySession>,
75
78
  PasswordIdentitySurface<FakeIdentityActor>,
76
- MagicLinkIdentitySurface,
79
+ MagicLinkIdentitySurface<FakeIdentityActor>,
77
80
  OAuthIdentitySurface,
78
81
  OrganizationIdentitySurface,
79
- MfaIdentitySurface<Factor>
82
+ MfaIdentitySurface<Factor>,
83
+ EmailVerificationIdentitySurface
80
84
  {
81
85
  /** Driver implementation name. */
82
86
  readonly name = 'fake'
@@ -91,6 +95,8 @@ export class FakeIdentity
91
95
  readonly #actors = new Map<string, StoredActor>()
92
96
  readonly #sessions = new Map<string, StoredSession>()
93
97
  readonly #resetTokens = new Map<string, string>()
98
+ readonly #magicLinkTokens = new Map<string, string>()
99
+ readonly #verifyTokens = new Map<string, string>()
94
100
  readonly #challenges = new Map<string, { actorId: string; challenge: MfaChallenge<Factor> }>()
95
101
  #lastSession: FakeIdentitySession | null = null
96
102
  #nextActor = 1
@@ -107,6 +113,25 @@ export class FakeIdentity
107
113
  return { users: this.#actors.size, sessions: this.#sessions.size }
108
114
  }
109
115
 
116
+ #openSession(actor: StoredActor): FakeIdentityActor {
117
+ const session: StoredSession = {
118
+ id: `session-${this.#nextSession++}`,
119
+ actorId: actor.id,
120
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
121
+ active: true,
122
+ }
123
+ this.#sessions.set(session.id, session)
124
+ this.#lastSession = this.#copySession(session)
125
+ this.#cookies.set(sessionCookie, session.id, {
126
+ httpOnly: true,
127
+ maxAge: 60 * 60,
128
+ path: '/',
129
+ sameSite: 'lax',
130
+ secure: true,
131
+ })
132
+ return { id: actor.id, email: actor.email }
133
+ }
134
+
110
135
  /** Returns the current actor or null for an anonymous request. */
111
136
  async user(): Promise<FakeIdentityActor | null> {
112
137
  const session = this.#currentSession()
@@ -135,7 +160,12 @@ export class FakeIdentity
135
160
  metadata: { resource: 'actor', key: 'email' },
136
161
  })
137
162
  }
138
- const actor: StoredActor = { id: `actor-${this.#nextActor++}`, email, password }
163
+ const actor: StoredActor = {
164
+ id: `actor-${this.#nextActor++}`,
165
+ email,
166
+ password,
167
+ emailVerified: false,
168
+ }
139
169
  this.#actors.set(email, actor)
140
170
  return { id: actor.id, email: actor.email }
141
171
  }
@@ -144,23 +174,7 @@ export class FakeIdentity
144
174
  async signInWithPassword(email: string, password: string): Promise<FakeIdentityActor> {
145
175
  const actor = this.#actors.get(email)
146
176
  if (!actor || actor.password !== password) throw unauthenticated('Invalid credentials.')
147
-
148
- const session: StoredSession = {
149
- id: `session-${this.#nextSession++}`,
150
- actorId: actor.id,
151
- expiresAt: new Date(Date.now() + 60 * 60 * 1000),
152
- active: true,
153
- }
154
- this.#sessions.set(session.id, session)
155
- this.#lastSession = this.#copySession(session)
156
- this.#cookies.set(sessionCookie, session.id, {
157
- httpOnly: true,
158
- maxAge: 60 * 60,
159
- path: '/',
160
- sameSite: 'lax',
161
- secure: true,
162
- })
163
- return { id: actor.id, email: actor.email }
177
+ return this.#openSession(actor)
164
178
  }
165
179
 
166
180
  /** Records a deterministic recovery token for a known actor without revealing account absence. */
@@ -185,8 +199,19 @@ export class FakeIdentity
185
199
  actor.password = password
186
200
  }
187
201
 
188
- /** Accepts a magic-link request without revealing account existence. */
189
- async sendMagicLink(_email: string, _redirectTo?: string): Promise<void> {}
202
+ /** Records a deterministic sign-in token for a known actor without revealing account absence. */
203
+ async sendMagicLink(email: string, _redirectTo?: string): Promise<void> {
204
+ if (this.#actors.has(email)) this.#magicLinkTokens.set('assay-magic-link-token', email)
205
+ }
206
+
207
+ /** Authenticates an actor with the token a magic link carried. */
208
+ async signInWithMagicLink(token: string): Promise<FakeIdentityActor> {
209
+ const email = this.#magicLinkTokens.get(token)
210
+ const actor = email ? this.#actors.get(email) : undefined
211
+ if (!email || !actor) throw unauthenticated('Invalid sign-in link.')
212
+ this.#magicLinkTokens.delete(token)
213
+ return this.#openSession(actor)
214
+ }
190
215
 
191
216
  /** Links a completed authorization to the authenticated actor. */
192
217
  async linkOAuthIdentity(_provider: string, _code: string): Promise<void> {
@@ -219,6 +244,22 @@ export class FakeIdentity
219
244
  return { ...challenge }
220
245
  }
221
246
 
247
+ /** Records a deterministic confirmation token for a known actor without revealing account absence. */
248
+ async sendEmailVerification(email?: string): Promise<void> {
249
+ const target = email ?? (await this.user())?.email
250
+ if (!target) throw invalid('email', 'No email to verify.')
251
+ if (this.#actors.has(target)) this.#verifyTokens.set('assay-verify-token', target)
252
+ }
253
+
254
+ /** Confirms an email address after validating a confirmation token. */
255
+ async verifyEmail(token: string): Promise<void> {
256
+ const email = this.#verifyTokens.get(token)
257
+ const actor = email ? this.#actors.get(email) : undefined
258
+ if (!email || !actor) throw invalid('token', 'Invalid email verification token.')
259
+ actor.emailVerified = true
260
+ this.#verifyTokens.delete(token)
261
+ }
262
+
222
263
  /** Verifies the deterministic code accepted by the in-memory implementation. */
223
264
  async verifyMfa(challengeId: string, code: string): Promise<void> {
224
265
  const pending = this.#challenges.get(challengeId)
package/src/fakes/logs.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type LogDriver, type LogRecord, type TraceLogSurface, type TraceSpan } from '@avelonjs/core'
1
+ import {
2
+ type LogDriver,
3
+ type LogRecord,
4
+ type TraceLogSurface,
5
+ type TraceSpan,
6
+ } from '@avelonjs/core'
2
7
 
3
8
  const capabilities = { traces: true } as const
4
9
 
package/src/harness.ts CHANGED
@@ -21,6 +21,10 @@ export interface SuiteContext<TDriver> {
21
21
  cleanup?(driver: TDriver): Promise<void> | void
22
22
  /** Returns a vendor-issued password recovery token for the fixture identity. */
23
23
  recoveryToken?(email: string): Promise<string>
24
+ /** Returns a vendor-issued email confirmation token for the fixture identity. */
25
+ emailVerificationToken?(email: string): Promise<string>
26
+ /** Returns the vendor-issued token a sent magic link carried. */
27
+ magicLinkToken?(email: string): Promise<string>
24
28
  /** Returns the response code for a pending MFA challenge. */
25
29
  mfaCode?(challengeId: string): Promise<string>
26
30
  /** Verified sender address used by live mail providers. */
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
+ Conflict,
3
4
  Invalid,
4
5
  Unauthenticated,
5
6
  type IdentityCapabilities,
6
7
  type IdentityDriver,
8
+ type EmailVerificationIdentitySurface,
7
9
  type MagicLinkIdentitySurface,
8
10
  type MfaIdentitySurface,
9
11
  type OAuthIdentitySurface,
@@ -72,6 +74,14 @@ async function expectUnauthenticated(operation: () => Promise<unknown>): Promise
72
74
  return error
73
75
  }
74
76
 
77
+ async function expectConflict(operation: () => Promise<unknown>): Promise<Conflict> {
78
+ const error = await captureFailure(operation)
79
+ expect(error).toBeInstanceOf(Conflict)
80
+ if (!(error instanceof Conflict)) throw new Error('Expected Conflict after taxonomy assertion.')
81
+ expect(error.code).toBe('CONFLICT')
82
+ return error
83
+ }
84
+
75
85
  async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
76
86
  const error = await captureFailure(operation)
77
87
  expect(error).toBeInstanceOf(Invalid)
@@ -84,7 +94,9 @@ function hasPasswords(driver: AssayDriver): driver is AssayDriver & PasswordIden
84
94
  return driver.capabilities.passwords
85
95
  }
86
96
 
87
- function hasMagicLinks(driver: AssayDriver): driver is AssayDriver & MagicLinkIdentitySurface {
97
+ function hasMagicLinks(
98
+ driver: AssayDriver,
99
+ ): driver is AssayDriver & MagicLinkIdentitySurface<unknown> {
88
100
  return driver.capabilities.magicLinks
89
101
  }
90
102
 
@@ -102,13 +114,20 @@ function hasMfa(driver: AssayDriver): driver is AssayDriver & MfaIdentitySurface
102
114
  return driver.capabilities.mfa.length > 0
103
115
  }
104
116
 
117
+ function hasEmailVerification(
118
+ driver: AssayDriver,
119
+ ): driver is AssayDriver & EmailVerificationIdentitySurface {
120
+ return driver.capabilities.emailVerification
121
+ }
122
+
105
123
  /**
106
124
  * Registers the portable identity contract conformance suite.
107
125
  *
108
126
  * `context.create()` returns the config-time factory, whose pinned signature is
109
127
  * `(cookies: RequestCookies) => TDriver`. Each call to that factory receives one request-scoped
110
- * cookie store. Recovery-token and MFA-code providers may supply vendor-issued fixture values;
111
- * otherwise password-capable fixtures accept `assay-reset-token` and MFA fixtures accept `123456`.
128
+ * cookie store. Recovery-token, email-verification, and MFA-code providers may supply vendor-issued
129
+ * fixture values; otherwise password-capable fixtures accept `assay-reset-token`, email-verification
130
+ * fixtures accept `assay-verify-token`, and MFA fixtures accept `123456`.
112
131
  *
113
132
  * @param context Fresh identity factories and optional cleanup.
114
133
  */
@@ -129,10 +148,15 @@ export function identitySuite<TDriver extends AssayDriver>(
129
148
  'updatePassword',
130
149
  ],
131
150
  ],
132
- ['magicLinks', driver.capabilities.magicLinks, ['sendMagicLink']],
151
+ ['magicLinks', driver.capabilities.magicLinks, ['sendMagicLink', 'signInWithMagicLink']],
133
152
  ['oauth', driver.capabilities.oauth, ['linkOAuthIdentity']],
134
153
  ['organizations', driver.capabilities.organizations, ['organizations', 'useOrganization']],
135
154
  ['mfa', driver.capabilities.mfa.length > 0, ['challengeMfa', 'verifyMfa']],
155
+ [
156
+ 'emailVerification',
157
+ driver.capabilities.emailVerification,
158
+ ['sendEmailVerification', 'verifyEmail'],
159
+ ],
136
160
  ]
137
161
  for (const [capability, declared, methods] of surfaces) {
138
162
  assertCapabilitySurface(driver, capability, declared, methods)
@@ -191,6 +215,19 @@ export function identitySuite<TDriver extends AssayDriver>(
191
215
  },
192
216
  )
193
217
 
218
+ assay(
219
+ context,
220
+ 'refuses a second registration of the same email as Conflict',
221
+ async (driver) => {
222
+ if (!hasPasswords(driver)) return
223
+ await driver.register('actor@example.test', 'initial-password')
224
+ // Every driver has its own vendor wording for this. Application code branches on the
225
+ // taxonomy member, so a driver that reports it as Invalid makes "email is taken" a string
226
+ // match against a vendor message.
227
+ await expectConflict(() => driver.register('actor@example.test', 'another-password'))
228
+ },
229
+ )
230
+
194
231
  assay(context, 'signs out the current request and clears its session', async (driver) => {
195
232
  if (!hasPasswords(driver)) return
196
233
  await driver.register('actor@example.test', 'initial-password')
@@ -244,12 +281,30 @@ export function identitySuite<TDriver extends AssayDriver>(
244
281
  expect(await driver.signInWithPassword('actor@example.test', 'reset-password')).not.toBeNull()
245
282
  })
246
283
 
247
- assay(context, 'sends passwordless magic links when declared', async (driver) => {
248
- if (!hasMagicLinks(driver)) return
249
- await expect(
250
- driver.sendMagicLink('actor@example.test', 'https://app.example.test/complete'),
251
- ).resolves.toBeUndefined()
252
- })
284
+ assay(
285
+ context,
286
+ 'sends a magic link and signs in with the token it carried',
287
+ async (driver, cookies) => {
288
+ if (!hasMagicLinks(driver)) return
289
+ await expect(
290
+ driver.sendMagicLink('actor@example.test', 'https://app.example.test/complete'),
291
+ ).resolves.toBeUndefined()
292
+ // Sending is half a flow. A driver that cannot redeem the token it just mailed leaves the
293
+ // capability declared and unusable, which is the state this assertion exists to catch.
294
+ if (!hasPasswords(driver)) return
295
+ await driver.register('actor@example.test', 'initial-password')
296
+ await driver.sendMagicLink('actor@example.test')
297
+ const token = context.magicLinkToken
298
+ ? await context.magicLinkToken('actor@example.test')
299
+ : 'assay-magic-link-token'
300
+ const signedIn = await driver.signInWithMagicLink(token)
301
+ expect(signedIn).not.toBeNull()
302
+ expect(await driver.user()).toEqual(signedIn)
303
+ expect(await driver.session()).not.toBeNull()
304
+ expect(cookies.size).toBeGreaterThan(0)
305
+ await expectUnauthenticated(() => driver.signInWithMagicLink('not-a-real-token'))
306
+ },
307
+ )
253
308
 
254
309
  assay(context, 'links a completed OAuth identity when declared', async (driver) => {
255
310
  if (!hasPasswords(driver) || !hasOAuth(driver)) return
@@ -289,5 +344,22 @@ export function identitySuite<TDriver extends AssayDriver>(
289
344
  await expectInvalid(() => driver.challengeMfa('undeclared-factor'))
290
345
  },
291
346
  )
347
+
348
+ assay(
349
+ context,
350
+ 'sends email confirmation without revealing accounts and verifies the token',
351
+ async (driver) => {
352
+ if (!hasEmailVerification(driver) || !hasPasswords(driver)) return
353
+ await driver.register('actor@example.test', 'initial-password')
354
+ await driver.signInWithPassword('actor@example.test', 'initial-password')
355
+ await expect(driver.sendEmailVerification()).resolves.toBeUndefined()
356
+ await expect(driver.sendEmailVerification('missing@example.test')).resolves.toBeUndefined()
357
+ const verificationToken = context.emailVerificationToken
358
+ ? await context.emailVerificationToken('actor@example.test')
359
+ : 'assay-verify-token'
360
+ await expect(driver.verifyEmail(verificationToken)).resolves.toBeUndefined()
361
+ await expectInvalid(() => driver.verifyEmail('invalid-token'))
362
+ },
363
+ )
292
364
  })
293
365
  }
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { Invalid, Unauthenticated, type SocialCapabilities, type SocialDriver } from '@avelonjs/core'
2
+ import {
3
+ Invalid,
4
+ Unauthenticated,
5
+ type SocialCapabilities,
6
+ type SocialDriver,
7
+ } from '@avelonjs/core'
3
8
  import { captureFailure, type SuiteContext } from '../harness'
4
9
 
5
10
  type AssayDriver = SocialDriver<SocialCapabilities<string>, unknown, unknown>