@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 +1 -1
- package/src/fakes/identity.ts +63 -22
- package/src/fakes/logs.ts +6 -1
- package/src/harness.ts +4 -0
- package/src/suites/identity.ts +82 -10
- package/src/suites/social.ts +6 -1
package/package.json
CHANGED
package/src/fakes/identity.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
/**
|
|
189
|
-
async sendMagicLink(
|
|
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 {
|
|
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. */
|
package/src/suites/identity.ts
CHANGED
|
@@ -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(
|
|
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
|
|
111
|
-
* otherwise password-capable fixtures accept `assay-reset-token
|
|
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(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
}
|
package/src/suites/social.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
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>
|