@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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +50 -0
- package/src/fakes/ai.ts +113 -0
- package/src/fakes/cache.ts +153 -0
- package/src/fakes/database.ts +612 -0
- package/src/fakes/flags.ts +67 -0
- package/src/fakes/identity.ts +253 -0
- package/src/fakes/logs.ts +101 -0
- package/src/fakes/mail.ts +140 -0
- package/src/fakes/notifications.ts +73 -0
- package/src/fakes/payments.ts +188 -0
- package/src/fakes/queue.ts +205 -0
- package/src/fakes/ratelimit.ts +143 -0
- package/src/fakes/realtime.ts +96 -0
- package/src/fakes/search.ts +127 -0
- package/src/fakes/social.ts +83 -0
- package/src/fakes/storage.ts +125 -0
- package/src/fakes/tokens.ts +106 -0
- package/src/harness.ts +103 -0
- package/src/index.ts +21 -0
- package/src/suites/ai.ts +167 -0
- package/src/suites/cache.ts +174 -0
- package/src/suites/database.ts +853 -0
- package/src/suites/flags.ts +80 -0
- package/src/suites/identity.ts +293 -0
- package/src/suites/index.ts +16 -0
- package/src/suites/logs.ts +116 -0
- package/src/suites/mail.ts +160 -0
- package/src/suites/notifications.ts +119 -0
- package/src/suites/payments.ts +200 -0
- package/src/suites/queue.ts +238 -0
- package/src/suites/ratelimit.ts +134 -0
- package/src/suites/realtime.ts +123 -0
- package/src/suites/search.ts +115 -0
- package/src/suites/social.ts +129 -0
- package/src/suites/storage.ts +158 -0
- package/src/suites/tokens.ts +105 -0
|
@@ -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'
|