@avelonjs/conformance 0.1.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.
@@ -0,0 +1,127 @@
1
+ import {
2
+ Invalid,
3
+ type SearchCapabilities,
4
+ type SearchDocument,
5
+ type SearchDriver,
6
+ type SearchOptions,
7
+ type SearchResult,
8
+ } from '@avelonjs/core'
9
+
10
+ interface FakeSearchRaw {
11
+ readonly indexes: number
12
+ readonly documents: number
13
+ }
14
+
15
+ class FakeSearchBase<TCapabilities extends SearchCapabilities> implements SearchDriver<
16
+ TCapabilities,
17
+ FakeSearchRaw
18
+ > {
19
+ readonly name = 'fake'
20
+ readonly instance: string
21
+ readonly capabilities: TCapabilities
22
+ readonly #indexes = new Map<string, Map<string, SearchDocument>>()
23
+
24
+ constructor(capabilities: TCapabilities, instance = 'default') {
25
+ this.capabilities = capabilities
26
+ this.instance = instance
27
+ }
28
+
29
+ raw(): FakeSearchRaw {
30
+ let documents = 0
31
+ for (const index of this.#indexes.values()) documents += index.size
32
+ return { indexes: this.#indexes.size, documents }
33
+ }
34
+
35
+ async index(name: string, documents: readonly SearchDocument[]): Promise<void> {
36
+ const index = this.#indexes.get(name) ?? new Map<string, SearchDocument>()
37
+ for (const document of documents) {
38
+ index.set(document.id, { id: document.id, fields: { ...document.fields } })
39
+ }
40
+ this.#indexes.set(name, index)
41
+ }
42
+
43
+ async remove(name: string, ids: readonly string[]): Promise<void> {
44
+ const index = this.#indexes.get(name)
45
+ for (const id of ids) index?.delete(id)
46
+ }
47
+
48
+ async query<TDocument extends SearchDocument = SearchDocument>(
49
+ name: string,
50
+ query: string,
51
+ options: SearchOptions = {},
52
+ ): Promise<SearchResult<TDocument>> {
53
+ this.validateOptions(options)
54
+ const needle = query.toLocaleLowerCase()
55
+ const matches = [...(this.#indexes.get(name)?.values() ?? [])].filter((document) => {
56
+ const matchesQuery =
57
+ needle.length === 0 ||
58
+ Object.values(document.fields).some(
59
+ (value) => typeof value === 'string' && value.toLocaleLowerCase().includes(needle),
60
+ )
61
+ const matchesFilters = Object.entries(options.filters ?? {}).every(([field, value]) =>
62
+ Object.is(document.fields[field], value),
63
+ )
64
+ return matchesQuery && matchesFilters
65
+ })
66
+
67
+ const offset = options.offset ?? 0
68
+ const limit = options.limit ?? matches.length
69
+ const page = matches.slice(offset, offset + limit)
70
+ const facets = options.facets
71
+ ? Object.fromEntries(
72
+ options.facets.map((field) => {
73
+ const counts: Record<string, number> = {}
74
+ for (const document of matches) {
75
+ const value = document.fields[field]
76
+ if (value === undefined) continue
77
+ const key = String(value)
78
+ counts[key] = (counts[key] ?? 0) + 1
79
+ }
80
+ return [field, counts]
81
+ }),
82
+ )
83
+ : undefined
84
+
85
+ return {
86
+ hits: page.map((document) => ({ document: document as TDocument, score: 1 })),
87
+ total: matches.length,
88
+ ...(facets === undefined ? {} : { facets }),
89
+ }
90
+ }
91
+
92
+ private validateOptions(options: SearchOptions): void {
93
+ const fields: Record<string, readonly string[]> = {}
94
+ if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 0)) {
95
+ fields.limit = ['Limit must be a non-negative integer.']
96
+ }
97
+ if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
98
+ fields.offset = ['Offset must be a non-negative integer.']
99
+ }
100
+ if (options.facets && !this.capabilities.facets) {
101
+ fields.facets = ['Facet queries are not declared by this driver.']
102
+ }
103
+ if (Object.keys(fields).length > 0) {
104
+ throw new Invalid('Invalid search options.', { metadata: { fields } })
105
+ }
106
+ }
107
+ }
108
+
109
+ const allCapabilities = { facets: true } as const
110
+
111
+ /** In-memory search reference implementation with facet support. */
112
+ export class FakeSearch extends FakeSearchBase<typeof allCapabilities> {
113
+ /** Creates an isolated search connection with facets enabled. */
114
+ constructor(instance = 'default') {
115
+ super(allCapabilities, instance)
116
+ }
117
+ }
118
+
119
+ const noFacetCapabilities = { facets: false } as const
120
+
121
+ /** In-memory search reference implementation without facet support. */
122
+ export class FakeSearchWithoutFacets extends FakeSearchBase<typeof noFacetCapabilities> {
123
+ /** Creates an isolated search connection with facets disabled. */
124
+ constructor(instance = 'default') {
125
+ super(noFacetCapabilities, instance)
126
+ }
127
+ }
@@ -0,0 +1,83 @@
1
+ import { Invalid, Unauthenticated, type SocialDriver, type SocialIdentity } from '@avelonjs/core'
2
+
3
+ const capabilities = {
4
+ providers: ['primary', 'secondary'] as const,
5
+ } as const
6
+
7
+ type Provider = (typeof capabilities.providers)[number]
8
+
9
+ interface FakeSocialProfile {
10
+ displayName: string
11
+ }
12
+
13
+ interface FakeSocialRaw {
14
+ pendingStates: number
15
+ }
16
+
17
+ function invalid(field: string, message: string): Invalid {
18
+ return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
19
+ }
20
+
21
+ function unauthenticated(message: string): Unauthenticated {
22
+ return new Unauthenticated(message, { metadata: { guard: 'social' } })
23
+ }
24
+
25
+ function isProvider(provider: string): provider is Provider {
26
+ return capabilities.providers.some((candidate) => candidate === provider)
27
+ }
28
+
29
+ /** In-memory social authorization reference implementation used by conformance tests. */
30
+ export class FakeSocial implements SocialDriver<
31
+ typeof capabilities,
32
+ FakeSocialRaw,
33
+ FakeSocialProfile
34
+ > {
35
+ /** Driver implementation name. */
36
+ readonly name = 'fake'
37
+
38
+ /** Configured social connection name. */
39
+ readonly instance = 'default'
40
+
41
+ /** Exact configured-provider declaration. */
42
+ readonly capabilities = capabilities
43
+
44
+ readonly #states = new Map<Provider, string>()
45
+ #nextState = 1
46
+
47
+ /** Returns the count of pending authorization states. */
48
+ raw(): FakeSocialRaw {
49
+ return { pendingStates: this.#states.size }
50
+ }
51
+
52
+ /** Builds an authorization URL for a configured provider and records its CSRF state. */
53
+ async redirect(provider: string, callbackUrl: string, state?: string): Promise<string> {
54
+ if (!isProvider(provider)) throw invalid('provider', 'Provider is not configured.')
55
+ const expectedState = state ?? `assay-state-${this.#nextState++}`
56
+ this.#states.set(provider, expectedState)
57
+ const url = new URL(`https://auth.example.test/${provider}/authorize`)
58
+ url.searchParams.set('redirect_uri', callbackUrl)
59
+ url.searchParams.set('state', expectedState)
60
+ return url.toString()
61
+ }
62
+
63
+ /** Verifies authorization state and returns normalized social identity data. */
64
+ async callback(
65
+ provider: string,
66
+ params: Readonly<Record<string, string>>,
67
+ _callbackUrl: string,
68
+ ): Promise<SocialIdentity<FakeSocialProfile>> {
69
+ if (!isProvider(provider)) throw invalid('provider', 'Provider is not configured.')
70
+ const expectedState = this.#states.get(provider)
71
+ if (!expectedState || !params.state || params.state !== expectedState) {
72
+ throw unauthenticated('Authorization state could not be verified.')
73
+ }
74
+ this.#states.delete(provider)
75
+ if (params.error) throw unauthenticated(`Authorization failed: ${params.error}.`)
76
+ if (!params.code) throw invalid('code', 'Authorization code is required.')
77
+ return {
78
+ provider,
79
+ subject: `subject-${params.code}`,
80
+ profile: { displayName: 'Assay Actor' },
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ NotFound,
3
+ type SignedUrlStorageSurface,
4
+ type StorageDriver,
5
+ type StorageObject,
6
+ } from '@avelonjs/core'
7
+
8
+ const capabilities = {
9
+ signedUrls: true,
10
+ transforms: [] as const,
11
+ } as const
12
+
13
+ interface StoredObject {
14
+ contents: Uint8Array
15
+ contentType?: string
16
+ }
17
+
18
+ interface FakeStorageRaw {
19
+ objects: number
20
+ }
21
+
22
+ async function readContents(contents: Uint8Array | AsyncIterable<Uint8Array>): Promise<Uint8Array> {
23
+ if (contents instanceof Uint8Array) return contents.slice()
24
+
25
+ const chunks: Uint8Array[] = []
26
+ let size = 0
27
+ for await (const chunk of contents) {
28
+ const copy = chunk.slice()
29
+ chunks.push(copy)
30
+ size += copy.byteLength
31
+ }
32
+
33
+ const result = new Uint8Array(size)
34
+ let offset = 0
35
+ for (const chunk of chunks) {
36
+ result.set(chunk, offset)
37
+ offset += chunk.byteLength
38
+ }
39
+ return result
40
+ }
41
+
42
+ function objectUrl(contents: Uint8Array, contentType?: string): string {
43
+ const buffer = new ArrayBuffer(contents.byteLength)
44
+ new Uint8Array(buffer).set(contents)
45
+ return URL.createObjectURL(new Blob([buffer], { type: contentType }))
46
+ }
47
+
48
+ /** In-memory storage reference implementation used by conformance and application tests. */
49
+ export class FakeStorage
50
+ implements StorageDriver<typeof capabilities, FakeStorageRaw>, SignedUrlStorageSurface
51
+ {
52
+ /** Driver implementation name. */
53
+ readonly name = 'fake'
54
+
55
+ /** Configured storage disk name. */
56
+ readonly instance: string
57
+
58
+ /** Exact optional-feature declaration. */
59
+ readonly capabilities = capabilities
60
+
61
+ readonly #objects = new Map<string, StoredObject>()
62
+
63
+ /** Creates an isolated storage disk with the supplied instance name. */
64
+ constructor(instance = 'default') {
65
+ this.instance = instance
66
+ }
67
+
68
+ /** Returns the number of objects held by this isolated fake disk. */
69
+ raw(): FakeStorageRaw {
70
+ return { objects: this.#objects.size }
71
+ }
72
+
73
+ /** Stores an exact byte copy and returns normalized object metadata. */
74
+ async put(
75
+ path: string,
76
+ contents: Uint8Array | AsyncIterable<Uint8Array>,
77
+ options?: { readonly contentType?: string },
78
+ ): Promise<StorageObject> {
79
+ const stored: StoredObject = {
80
+ contents: await readContents(contents),
81
+ ...(options?.contentType === undefined ? {} : { contentType: options.contentType }),
82
+ }
83
+ this.#objects.set(path, stored)
84
+ return {
85
+ path,
86
+ size: stored.contents.byteLength,
87
+ ...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
88
+ }
89
+ }
90
+
91
+ /** Reads an exact byte copy or raises `NotFound` when the path is absent. */
92
+ async get(path: string): Promise<Uint8Array> {
93
+ const stored = this.#objects.get(path)
94
+ if (!stored) {
95
+ throw new NotFound(`Storage object ${path} was not found.`, {
96
+ metadata: { resource: 'storage-object', identifier: path },
97
+ })
98
+ }
99
+ return stored.contents.slice()
100
+ }
101
+
102
+ /** Deletes an object when present. */
103
+ async delete(path: string): Promise<void> {
104
+ this.#objects.delete(path)
105
+ }
106
+
107
+ /** Reports whether an object exists. */
108
+ async exists(path: string): Promise<boolean> {
109
+ return this.#objects.has(path)
110
+ }
111
+
112
+ /** Creates a fetchable read URL and revokes it when its lifetime elapses. */
113
+ async signedUrl(path: string, expiresInSeconds: number): Promise<string> {
114
+ const stored = this.#objects.get(path)
115
+ if (!stored) {
116
+ throw new NotFound(`Storage object ${path} was not found.`, {
117
+ metadata: { resource: 'storage-object', identifier: path },
118
+ })
119
+ }
120
+
121
+ const url = objectUrl(stored.contents, stored.contentType)
122
+ setTimeout(() => URL.revokeObjectURL(url), expiresInSeconds * 1000)
123
+ return url
124
+ }
125
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ Unauthenticated,
3
+ type IssuedToken,
4
+ type TokenDriver,
5
+ type TokenIssueOptions,
6
+ type TokenRecord,
7
+ } from '@avelonjs/core'
8
+
9
+ const capabilities = {
10
+ abilities: true,
11
+ expiration: true,
12
+ } as const
13
+
14
+ interface StoredToken extends TokenRecord {
15
+ hash: string
16
+ revoked: boolean
17
+ }
18
+
19
+ interface FakeTokenRaw {
20
+ hashes: readonly string[]
21
+ }
22
+
23
+ function unauthenticated(): Unauthenticated {
24
+ return new Unauthenticated('The API token is invalid or expired.', {
25
+ metadata: { guard: 'tokens' },
26
+ })
27
+ }
28
+
29
+ async function hash(plainText: string): Promise<string> {
30
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(plainText))
31
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
32
+ }
33
+
34
+ function copyRecord(token: TokenRecord): TokenRecord {
35
+ return {
36
+ id: token.id,
37
+ subject: token.subject,
38
+ name: token.name,
39
+ abilities: [...token.abilities],
40
+ createdAt: new Date(token.createdAt),
41
+ expiresAt: token.expiresAt ? new Date(token.expiresAt) : null,
42
+ }
43
+ }
44
+
45
+ /** In-memory API-token reference implementation used by conformance and application tests. */
46
+ export class FakeTokens implements TokenDriver<typeof capabilities, FakeTokenRaw> {
47
+ /** Driver implementation name. */
48
+ readonly name = 'fake'
49
+
50
+ /** Configured token issuer name. */
51
+ readonly instance = 'default'
52
+
53
+ /** Exact issuance-feature declaration. */
54
+ readonly capabilities = capabilities
55
+
56
+ readonly #tokens: StoredToken[] = []
57
+ #nextId = 1
58
+
59
+ /** Returns stored hashes without exposing plaintext token values. */
60
+ raw(): FakeTokenRaw {
61
+ return { hashes: this.#tokens.map((token) => token.hash) }
62
+ }
63
+
64
+ /** Authenticates an active plaintext token and returns its safe metadata. */
65
+ async verify(plainText: string): Promise<TokenRecord> {
66
+ const candidateHash = await hash(plainText)
67
+ const token = this.#tokens.find((candidate) => candidate.hash === candidateHash)
68
+ if (
69
+ !token ||
70
+ token.revoked ||
71
+ (token.expiresAt !== null && token.expiresAt.getTime() <= Date.now())
72
+ ) {
73
+ throw unauthenticated()
74
+ }
75
+ return copyRecord(token)
76
+ }
77
+
78
+ /** Issues a token and returns its plaintext exactly once. */
79
+ async issue(name: string, options: TokenIssueOptions = {}): Promise<IssuedToken> {
80
+ const id = `token-${this.#nextId++}`
81
+ const plainText = `avelon-assay-${id}-${crypto.randomUUID()}`
82
+ const record: StoredToken = {
83
+ id,
84
+ subject: 'assay-subject',
85
+ name,
86
+ abilities: [...(options.abilities ?? [])],
87
+ createdAt: new Date(),
88
+ expiresAt: options.expiresAt ? new Date(options.expiresAt) : null,
89
+ hash: await hash(plainText),
90
+ revoked: false,
91
+ }
92
+ this.#tokens.push(record)
93
+ return { ...copyRecord(record), plainText }
94
+ }
95
+
96
+ /** Lists safe token metadata for the current actor. */
97
+ async list(): Promise<readonly TokenRecord[]> {
98
+ return this.#tokens.map(copyRecord)
99
+ }
100
+
101
+ /** Revokes one token by stable identifier. */
102
+ async revoke(id: string): Promise<void> {
103
+ const token = this.#tokens.find((candidate) => candidate.id === id)
104
+ if (token) token.revoked = true
105
+ }
106
+ }
package/src/harness.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared harness for every contract conformance suite.
3
+ *
4
+ * A suite is written from the contract by an author who does not implement it, and is run by both
5
+ * the contract's reference fake and every real driver. That is what makes a passing driver mean
6
+ * something: the assertions were not written by the person trying to satisfy them.
7
+ */
8
+
9
+ /**
10
+ * Supplies the subject under test to a suite.
11
+ *
12
+ * `create` must return a driver in a known-empty state, because suites assume they own the world
13
+ * they observe. A driver backed by a live service is responsible for namespacing and cleanup.
14
+ */
15
+ export interface SuiteContext<TDriver> {
16
+ /** Human-readable name of the implementation, used in test titles. */
17
+ name: string
18
+ /** Returns a driver in a known-empty state. Called once per test. */
19
+ create(): Promise<TDriver> | TDriver
20
+ /** Releases anything `create` acquired. Called after every test, including failures. */
21
+ cleanup?(driver: TDriver): Promise<void> | void
22
+ /** Returns a vendor-issued password recovery token for the fixture identity. */
23
+ recoveryToken?(email: string): Promise<string>
24
+ /** Returns the response code for a pending MFA challenge. */
25
+ mfaCode?(challengeId: string): Promise<string>
26
+ /** Verified sender address used by live mail providers. */
27
+ mailSender?: string
28
+ /** Deliverable addresses used by the mail suite. */
29
+ mailRecipients?: {
30
+ readonly to: readonly [string, string]
31
+ readonly replyTo: readonly [string, string]
32
+ readonly cc: string
33
+ readonly bcc: string
34
+ readonly configured: string
35
+ }
36
+ /** Delay before a queued job becomes visible. */
37
+ queueDelayMs?: number
38
+ /** Time allowed for a delayed queued job to become visible. */
39
+ queueSettleMs?: number
40
+ /**
41
+ * Marks a suite as running against a live external service. Suites use this only to skip
42
+ * assertions that a fake cannot meaningfully make, never to weaken an assertion.
43
+ */
44
+ live?: boolean
45
+ }
46
+
47
+ /**
48
+ * Asserts a driver's declared capabilities match what it actually exposes, in both directions.
49
+ *
50
+ * Declaring a capability and omitting the method is a lie a caller discovers at runtime.
51
+ * Implementing a method while declaring the capability false is worse, because someone will call
52
+ * it and it will work until the driver is swapped. Both are failures here.
53
+ *
54
+ * This helper covers method-gated capabilities only. Capabilities that narrow accepted arguments,
55
+ * such as notification channels or rate-limit algorithms, are checked behaviorally by their suite:
56
+ * declared values must work and undeclared values must be rejected.
57
+ *
58
+ * @param driver The implementation under test.
59
+ * @param capability The capability key being checked.
60
+ * @param declared Whether the driver declares the capability as available.
61
+ * @param methods Method names that must exist when declared, and must be absent when not.
62
+ */
63
+ export function assertCapabilitySurface(
64
+ driver: object,
65
+ capability: string,
66
+ declared: boolean,
67
+ methods: readonly string[],
68
+ ): void {
69
+ for (const method of methods) {
70
+ const candidate: unknown = Reflect.get(driver, method)
71
+ const present = typeof candidate === 'function'
72
+ if (declared && !present) {
73
+ throw new Error(
74
+ `${capability} is declared available but ${method}() is missing. ` +
75
+ `A declared capability must be callable.`,
76
+ )
77
+ }
78
+ if (!declared && present) {
79
+ throw new Error(
80
+ `${capability} is declared unavailable but ${method}() is implemented. ` +
81
+ `Someone will call it, and it will break when the driver is swapped.`,
82
+ )
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Runs an operation expected to fail, and returns the thrown value for taxonomy assertions.
89
+ *
90
+ * Suites assert the framework error taxonomy rather than a vendor's code, because a vendor code
91
+ * reaching application code means the abstraction has already leaked through the error channel.
92
+ *
93
+ * @param operation The operation expected to throw.
94
+ * @returns The thrown value.
95
+ */
96
+ export async function captureFailure(operation: () => Promise<unknown>): Promise<unknown> {
97
+ try {
98
+ await operation()
99
+ } catch (error: unknown) {
100
+ return error
101
+ }
102
+ throw new Error('Expected the operation to fail, but it resolved.')
103
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Fakes and harness only. Suites import `bun:test` and live at `@avelonjs/conformance/suites`. */
2
+ export { assertCapabilitySurface, captureFailure } from './harness'
3
+ export type { SuiteContext } from './harness'
4
+ export { FakeAI } from './fakes/ai'
5
+ export { FakeCache } from './fakes/cache'
6
+ export { FakeDatabase } from './fakes/database'
7
+ export { FakeFlags, FakeFlagsWithoutTargeting } from './fakes/flags'
8
+ export { FakeIdentity } from './fakes/identity'
9
+ export { FakeLogs } from './fakes/logs'
10
+ export type { FakeTraceRecord } from './fakes/logs'
11
+ export { FakeMail } from './fakes/mail'
12
+ export { FakeNotifications, FakeNotificationsWithoutChannels } from './fakes/notifications'
13
+ export type { FakeSentNotification } from './fakes/notifications'
14
+ export { FakePayments } from './fakes/payments'
15
+ export { FakeQueue, FakeQueueWithoutRetries } from './fakes/queue'
16
+ export { FakeRateLimit, FakeRateLimitWithoutAlgorithms } from './fakes/ratelimit'
17
+ export { FakeRealtime } from './fakes/realtime'
18
+ export { FakeSearch, FakeSearchWithoutFacets } from './fakes/search'
19
+ export { FakeSocial } from './fakes/social'
20
+ export { FakeStorage } from './fakes/storage'
21
+ export { FakeTokens } from './fakes/tokens'